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:
@@ -362,6 +362,23 @@ first-use empty state only when one action stays primary. (4) **Domain vocabular
|
|||||||
via /council → Carbon empty-state + content, GOV.UK headings, WAI-ARIA APG button, NN/g. This
|
via /council → Carbon empty-state + content, GOV.UK headings, WAI-ARIA APG button, NN/g. This
|
||||||
bullet is the contract; cite it, not the source.)_
|
bullet is the contract; cite it, not the source.)_
|
||||||
|
|
||||||
|
**Resolved — the creation surface is builder-forward, Monaco-intact (3D).** The guided
|
||||||
|
path must be visible where the intent to make a chart forms: the library's creation surface
|
||||||
|
is a **primary "Build Chart"** (opens the Chart Builder) beside a **ghost "New JSON
|
||||||
|
snippet"** (the old instant create, unchanged) — two plain buttons with clear hierarchy, no
|
||||||
|
split/menu-button widget (two static choices don't earn an ARIA menu). The expert path
|
||||||
|
stays one visible click away, never hidden (NN/g #6 recognition over recall, #7 flexibility/
|
||||||
|
efficiency; Carbon: one primary per surface, a tertiary for the secondary CTA). The builder
|
||||||
|
itself opens **without a preselected dataset** (it picks the most recently modified; a
|
||||||
|
header **Dataset picker** switches without leaving), and with an empty dataset library it
|
||||||
|
shows a **no-datasets state** per the Carbon no-data pattern: what the space does + one
|
||||||
|
primary next step ("Add a dataset" → the Datasets create form) — never a dead end. The
|
||||||
|
dataset-row "Build Chart" stays as the contextual shortcut; the onboarding canvas gains the
|
||||||
|
data-first door ("Build a chart from your data") beside its primary. _(Consulted via
|
||||||
|
/council → NN/g #6/#7, Carbon empty-states; recorded in
|
||||||
|
docs/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not
|
||||||
|
the source.)_
|
||||||
|
|
||||||
**Resolved — one live region per shared message.** When the same error feeds two surfaces
|
**Resolved — one live region per shared message.** When the same error feeds two surfaces
|
||||||
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
|
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
|
||||||
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
|
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
|
||||||
|
|||||||
@@ -25,6 +25,34 @@
|
|||||||
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
||||||
the quick "where are we" — read it first.
|
the quick "where are we" — read it first.
|
||||||
|
|
||||||
|
- **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
|
||||||
|
front door now has somewhere to be found).**
|
||||||
|
- **Library creation surface forked:** primary **Build Chart** (accent, takes the
|
||||||
|
slack) + ghost **New JSON snippet** (the old instant create) — two plain buttons,
|
||||||
|
no menu widget; labels collapse in two stages as the pane narrows (the long ghost
|
||||||
|
label first). New `chart` icon (rising columns, pane-icon rect style).
|
||||||
|
- **The builder opens un-targeted:** `init(null)` picks the most recently modified
|
||||||
|
dataset; a **Dataset picker** heads the config pane (replaces the "Building from"
|
||||||
|
line). Switching with an untouched config re-derives smart defaults; a built-on
|
||||||
|
config is **rebased** via the new core `rebaseBuilderConfig` (mark/title/size/
|
||||||
|
sort/stack/calculates/expression-filters kept; encodings + predicate filters bound
|
||||||
|
to columns the new dataset lacks shed; same-schema switch keeps everything).
|
||||||
|
- **No-datasets state** (Carbon no-data): what the builder does + primary "Add a
|
||||||
|
dataset" → Datasets create form. A defensive "Choose a dataset" chooser covers the
|
||||||
|
datasets-exist-but-none-loaded case. New `#build` hash serializes the
|
||||||
|
no-dataset builder (spec §01E table).
|
||||||
|
- **Onboarding gains the data-first door** ("Build a chart from your data", ghost
|
||||||
|
beside the primary; applies the workspace split up front since the builder can
|
||||||
|
create the first snippet). Dataset-row "Build Chart" unchanged (the contextual
|
||||||
|
shortcut).
|
||||||
|
- **Contract updated:** spec §02 (creation surface, onboarding), §06 (Opening
|
||||||
|
rewritten: doors, dataset picker, no-datasets state), §01E (`#build`); council
|
||||||
|
resolutions recorded in `architecture/10` §5 ("builder-forward, Monaco-intact").
|
||||||
|
- **Verified:** `typecheck` + `test` (873, +10: core rebase, store init(null)/
|
||||||
|
switchDataset, url-hash `#build`, modal empty states) + `eslint` + prettier.
|
||||||
|
**Owed:** a visual pass on the forked creation surface + builder picker/empty
|
||||||
|
states (user-driven, batched with the 2B visual debt).
|
||||||
|
|
||||||
- **2026-06-12 (polish batch)** — **the 2B visual-pass findings fixed; the parked council
|
- **2026-06-12 (polish batch)** — **the 2B visual-pass findings fixed; the parked council
|
||||||
batch resolved and applied; two capability gaps closed.**
|
batch resolved and applied; two capability gaps closed.**
|
||||||
- **Aggregation for any field type.** `validAggregateOps` replaces the quantitative-only
|
- **Aggregation for any field type.** `validAggregateOps` replaces the quantitative-only
|
||||||
@@ -400,7 +428,7 @@ builder only creates) and the disabled-button affordance (disabled-with-reason v
|
|||||||
park in `ux-second-pass.md` if non-obvious). _Spec impact: §02 (library row action) + §06
|
park in `ux-second-pass.md` if non-obvious). _Spec impact: §02 (library row action) + §06
|
||||||
(hydration + the strict gate)._
|
(hydration + the strict gate)._
|
||||||
|
|
||||||
**3D · Entry points & discoverability** — _decided 2026-06-12, council-reviewed; lands with or just before 3A_
|
**3D · Entry points & discoverability** — _done 2026-06-12 (council-reviewed; see status log); 3A opens onto it next_
|
||||||
Today the builder has **one entry, two levels deep**: Datasets modal → per-dataset "Build
|
Today the builder has **one entry, two levels deep**: Datasets modal → per-dataset "Build
|
||||||
Chart" (`DatasetsModal.tsx`) — the data model's shape ("charts come from datasets"), not
|
Chart" (`DatasetsModal.tsx`) — the data model's shape ("charts come from datasets"), not
|
||||||
the user's intent ("I want to make a chart"). Meanwhile the library's pinned primary
|
the user's intent ("I want to make a chart"). Meanwhile the library's pinned primary
|
||||||
@@ -423,9 +451,11 @@ The decided shape:
|
|||||||
ghost/tertiary **New JSON snippet** (today's instant create, unchanged). Two plain
|
ghost/tertiary **New JSON snippet** (today's instant create, unchanged). Two plain
|
||||||
buttons with clear hierarchy — no split/menu-button widget (two static choices don't
|
buttons with clear hierarchy — no split/menu-button widget (two static choices don't
|
||||||
earn an ARIA menu).
|
earn an ARIA menu).
|
||||||
2. **Dataset picker inside the builder** (header select, default = most recently used), so
|
2. **Dataset picker inside the builder** (header select, default = most recently
|
||||||
the builder opens without a preselected dataset. Build-time detail: switching datasets
|
modified), so the builder opens without a preselected dataset. Build-time decision
|
||||||
mid-build re-derives smart defaults vs. prunes incompatible encodings — decide then.
|
taken: switching with an **untouched** opening config re-derives fresh smart defaults;
|
||||||
|
switching a **built-on** config rebases it (`rebaseBuilderConfig` — chart-level intent
|
||||||
|
kept, bindings to missing columns shed; a same-schema dataset keeps everything).
|
||||||
Useful beyond 3D (switch data without leaving the builder).
|
Useful beyond 3D (switch data without leaving the builder).
|
||||||
3. **No-datasets empty state** in the builder, per the Carbon no-data pattern: what the
|
3. **No-datasets empty state** in the builder, per the Carbon no-data pattern: what the
|
||||||
builder does + one primary action ("Add a dataset" → Datasets modal). No dead end.
|
builder does + one primary action ("Add a dataset" → Datasets modal). No dead end.
|
||||||
@@ -526,8 +556,8 @@ Phase 1 1A actionable hints ✓ done
|
|||||||
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
|
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
|
||||||
Phase 2 2A value-or-field channels (Property model) ✓ done
|
Phase 2 2A value-or-field channels (Property model) ✓ done
|
||||||
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
|
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
|
||||||
Phase 3 3D entry points & discoverability ← next, with/just before 3A (its prerequisite)
|
Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
|
||||||
3A intent-first front door (Tier C) ← built on 2B; the defining feature
|
3A intent-first front door (Tier C) ← next; built on 2B; the defining feature
|
||||||
3B starter examples
|
3B starter examples
|
||||||
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
|
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
|
||||||
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
|
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
|
||||||
|
|||||||
@@ -82,13 +82,16 @@ The app reflects its current location in the URL hash so that reloading restores
|
|||||||
|
|
||||||
States and their hash forms:
|
States and their hash forms:
|
||||||
|
|
||||||
| State | Hash |
|
| State | Hash |
|
||||||
| --------------------------- | ------------------------------ |
|
| ------------------------------ | ------------------------------ |
|
||||||
| A selected snippet | `#snippet-<id>` |
|
| A selected snippet | `#snippet-<id>` |
|
||||||
| Datasets manager (list) | `#datasets` |
|
| Datasets manager (list) | `#datasets` |
|
||||||
| A specific dataset | `#datasets/dataset-<id>` |
|
| A specific dataset | `#datasets/dataset-<id>` |
|
||||||
| New-dataset form | `#datasets/new` |
|
| New-dataset form | `#datasets/new` |
|
||||||
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
|
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
|
||||||
|
| Chart Builder, no dataset open | `#build` |
|
||||||
|
|
||||||
|
`#build` is the Chart Builder opened with an empty dataset library (its no-datasets state). When datasets exist, an un-targeted builder open immediately lands on one (see _Chart Builder → Opening_), so the URL shows the dataset form instead.
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ The Snippet Library is the left pane and the primary entry point to the app. A *
|
|||||||
|
|
||||||
## The List
|
## The List
|
||||||
|
|
||||||
The list shows every saved snippet and is always visible. A persistent "Create New Snippet" affordance sits at the top of the list, above all snippets, so the user can always start a new snippet regardless of scroll position.
|
The list shows every saved snippet and is always visible. A persistent **creation surface** sits at the top of the list, above all snippets, so the user can always start a new chart regardless of scroll position. It offers the two ways in with a clear hierarchy:
|
||||||
|
|
||||||
|
- **Build Chart** — the primary action; opens the **Chart Builder** (see _Chart Builder_), the guided no-JSON path. The builder opens on the most recently modified dataset (or its no-datasets state when the library has none) — it is not gated on first selecting a dataset.
|
||||||
|
- **New JSON snippet** — a ghost/tertiary action beside it; creates and selects a new snippet directly in the editor (see _Snippet Operations_ → Create New), the expert path — always visible, one click, never hidden behind the guided one.
|
||||||
|
|
||||||
|
When the library pane is dragged narrow, the buttons shed their labels (the long ghost label first) and keep their icons and accessible names.
|
||||||
|
|
||||||
- The list shows all snippets, ordered newest-modified first by default (see _Sort_).
|
- The list shows all snippets, ordered newest-modified first by default (see _Sort_).
|
||||||
- A "Create New Snippet" item is pinned at the top of the list; activating it creates and selects a new snippet (see _Snippet Operations_).
|
|
||||||
- Selecting a snippet makes it the **active snippet**: it loads into the editor and preview, becomes highlighted in the list, and the URL updates to reflect the selected snippet so the state is shareable and survives a page reload (see _Application Shell & Navigation_).
|
- Selecting a snippet makes it the **active snippet**: it loads into the editor and preview, becomes highlighted in the list, and the URL updates to reflect the selected snippet so the state is shareable and survives a page reload (see _Application Shell & Navigation_).
|
||||||
- Exactly one snippet is active at a time.
|
- Exactly one snippet is active at a time.
|
||||||
- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search", with a hint to try a different term). This is the list's only empty state: a genuinely empty library never shows the list at all (see next).
|
- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search", with a hint to try a different term). This is the list's only empty state: a genuinely empty library never shows the list at all (see next).
|
||||||
@@ -17,8 +21,9 @@ The list shows every saved snippet and is always visible. A persistent "Create N
|
|||||||
|
|
||||||
When the library is empty — on first run, or after the user deletes their last snippet — the app does **not** seed placeholder content. Instead the **onboarding canvas takes the full workspace**, replacing the pane chrome (the pane toggle strip, the library list, the editor, and the preview): with no snippets, the library's create/search/sort/storage controls and the pane toggles have nothing to act on, so the welcome gets the whole width. The user starts from a deliberate choice rather than dropped into the middle of an unfamiliar spec.
|
When the library is empty — on first run, or after the user deletes their last snippet — the app does **not** seed placeholder content. Instead the **onboarding canvas takes the full workspace**, replacing the pane chrome (the pane toggle strip, the library list, the editor, and the preview): with no snippets, the library's create/search/sort/storage controls and the pane toggles have nothing to act on, so the welcome gets the whole width. The user starts from a deliberate choice rather than dropped into the middle of an unfamiliar spec.
|
||||||
|
|
||||||
- The canvas briefly identifies what Astrolabe is, then offers two ways to begin.
|
- The canvas briefly identifies what Astrolabe is, then offers the ways to begin.
|
||||||
- **Create your first snippet** — the primary action; starts a new snippet from the sample bar-chart template and opens it in the editor (identical to _Create New_ under _Snippet Operations_).
|
- **Create your first snippet** — the primary action; starts a new snippet from the sample bar-chart template and opens it in the editor (identical to _Create New_ under _Snippet Operations_).
|
||||||
|
- **Build a chart from your data** — the data-first door beside the primary; opens the **Chart Builder** over the canvas. With no datasets yet, the builder's no-datasets state explains itself and leads to "Add a dataset" (see _Chart Builder → Opening_) — the path never dead-ends. Opening the builder also lays the workspace out at the default split below, since creating from the builder leaves the canvas directly.
|
||||||
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
|
- An **example gallery** of a few simple snippets showcasing distinct Vega-Lite capabilities (e.g. a bar chart, a time-series line, a scatter plot, a stacked area, a donut, a binned histogram). Each example shows a **live preview** of the chart and a one-line description.
|
||||||
- **Add** on an example creates it as an ordinary snippet and makes it active (opening it in the editor).
|
- **Add** on an example creates it as an ordinary snippet and makes it active (opening it in the editor).
|
||||||
- **Add all** creates the whole set at once and makes one of them active.
|
- **Add all** creates the whole set at once and makes one of them active.
|
||||||
|
|||||||
@@ -6,15 +6,30 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a s
|
|||||||
|
|
||||||
## Opening
|
## Opening
|
||||||
|
|
||||||
- Launched from a selected dataset in the _Datasets_ manager via that dataset's "build chart" action.
|
The builder has several doors; the guided creation path must be visible where the intent to make a chart forms, not buried behind the data model.
|
||||||
- Opens as a modal dialog over the application; the URL reflects the dataset's "build" action so the open builder is shareable/restorable (see _Application Shell & Navigation_).
|
|
||||||
- On open, the builder loads the selected dataset, displays its name, and pre-populates sensible defaults (see below). If no dataset is available, it shows a "No dataset loaded" message and offers no controls.
|
- **The library's creation surface** — the primary **Build Chart** action (see _Snippet Library → The List_). Opens the builder with no preselected dataset; the builder picks the **most recently modified dataset** itself.
|
||||||
|
- **The onboarding canvas** — the data-first "Build a chart from your data" door (see _Snippet Library → First-Run & Empty Workspace_).
|
||||||
|
- **A dataset's "Build Chart" action** in the _Datasets_ manager — the contextual shortcut; opens the builder on that dataset.
|
||||||
|
- Opens as a modal dialog over the application; the URL reflects the loaded dataset's "build" form (or `#build` while no dataset is loaded) so the open builder is shareable/restorable (see _Application Shell & Navigation_).
|
||||||
|
- On open, the builder loads its dataset and pre-populates sensible defaults (see below).
|
||||||
|
|
||||||
|
### Dataset picker
|
||||||
|
|
||||||
|
Which data the chart builds from is itself a builder choice. A **Dataset** picker sits at the top of the configuration pane, showing the loaded dataset and letting the user switch to any other without leaving the builder.
|
||||||
|
|
||||||
|
- Switching while the configuration is still the untouched opening default **re-derives fresh smart defaults** for the new dataset.
|
||||||
|
- Switching after the user has built on the configuration **rebases** it instead: chart-level intent survives (mark, title/subtitle, explicit size, sort/stack, calculated fields, expression filters), while bindings to columns the new dataset lacks are shed (encodings cleared, predicate filters dropped). A same-schema dataset — the common switch — keeps everything.
|
||||||
|
|
||||||
|
### No datasets
|
||||||
|
|
||||||
|
With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
A two-pane modal:
|
A two-pane modal:
|
||||||
|
|
||||||
- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
|
- **Left — configuration:** the dataset picker, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
|
||||||
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
|
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
|
||||||
|
|
||||||
## Data (preview, filters, calculated fields)
|
## Data (preview, filters, calculated fields)
|
||||||
|
|||||||
@@ -13,10 +13,28 @@
|
|||||||
min-width: 0;
|
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;
|
margin: 0;
|
||||||
padding: var(--space-5);
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,14 +51,11 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datasetName {
|
/* The dataset picker pinned at the top of the config pane. */
|
||||||
margin: 0;
|
.datasetRow {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
color: var(--text-secondary);
|
align-items: center;
|
||||||
}
|
gap: var(--space-3);
|
||||||
|
|
||||||
.datasetName strong {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
|
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('ChartBuilderModal', () => {
|
|||||||
await Promise.resolve();
|
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
|
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,12 +155,33 @@ describe('ChartBuilderModal', () => {
|
|||||||
vi.useRealTimers();
|
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 () => {
|
await act(async () => {
|
||||||
root.render(<ChartBuilderModal />);
|
root.render(<ChartBuilderModal />);
|
||||||
await Promise.resolve();
|
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 () => {
|
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 { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { chartConfigFor } from '@core/vega-themes';
|
||||||
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
|
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 { useAppStore } from '../stores/AppStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import {
|
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() {
|
export function ChartBuilderModal() {
|
||||||
const datasetId = useChartBuilderStore((s) => s.datasetId);
|
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 mark = useChartBuilderStore((s) => s.config.mark);
|
||||||
const sort = useChartBuilderStore((s) => s.config.sort);
|
const sort = useChartBuilderStore((s) => s.config.sort);
|
||||||
const stack = useChartBuilderStore((s) => s.config.stack);
|
const stack = useChartBuilderStore((s) => s.config.stack);
|
||||||
@@ -1317,7 +1370,16 @@ export function ChartBuilderModal() {
|
|||||||
}, [warnings]);
|
}, [warnings]);
|
||||||
|
|
||||||
if (datasetId === null) {
|
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 (
|
return (
|
||||||
@@ -1326,9 +1388,7 @@ export function ChartBuilderModal() {
|
|||||||
<div className="visually-hidden" role="status" aria-live="polite">
|
<div className="visually-hidden" role="status" aria-live="polite">
|
||||||
{fixAnnouncement}
|
{fixAnnouncement}
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.datasetName}>
|
<DatasetPicker datasetId={datasetId} />
|
||||||
Building from <strong>{datasetName}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DataSection />
|
<DataSection />
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type IconName =
|
|||||||
| 'dataset' // "references a dataset" — Carbon DataTable
|
| 'dataset' // "references a dataset" — Carbon DataTable
|
||||||
| 'delete' // delete — Carbon TrashCan
|
| 'delete' // delete — Carbon TrashCan
|
||||||
| 'add' // add / create-new — Carbon Add
|
| '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
|
| 'search' // live library search — Carbon Search
|
||||||
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
|
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
|
||||||
| 'import' // import a workspace file — Carbon Upload (a file goes in)
|
| '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" />
|
<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" />,
|
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).
|
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
|
||||||
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" />
|
<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;
|
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 call to action — the accent button, matching the library's Create. */
|
||||||
.primary {
|
.primary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -52,6 +59,30 @@
|
|||||||
outline-offset: 2px;
|
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. */
|
/* The "or start from an example" header row, with Add all pushed to the end. */
|
||||||
.galleryHead {
|
.galleryHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { VisualizationSpec } from 'vega-embed';
|
|||||||
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
||||||
import { createSnippet as createSnippetRecord } from '@core/snippet';
|
import { createSnippet as createSnippetRecord } from '@core/snippet';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { chartConfigFor } from '@core/vega-themes';
|
||||||
|
import { openModal } from '../modals/ModalCoordinator';
|
||||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { usePanesStore } from '../stores/PanesStore';
|
import { usePanesStore } from '../stores/PanesStore';
|
||||||
@@ -105,6 +106,15 @@ export function Onboarding() {
|
|||||||
layoutWorkspace();
|
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 = () => {
|
const handleAddAll = () => {
|
||||||
// Stagger the timestamps so the first example (the bar chart) is the newest:
|
// 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
|
// it then sorts to the top of the library and `addSnippets` makes it active
|
||||||
@@ -130,9 +140,14 @@ export function Onboarding() {
|
|||||||
your device.
|
your device.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button type="button" className={styles.primary} onClick={handleCreate}>
|
<div className={styles.ctaRow}>
|
||||||
<Icon name="add" /> Create your first snippet
|
<button type="button" className={styles.primary} onClick={handleCreate}>
|
||||||
</button>
|
<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}>
|
<div className={styles.galleryHead}>
|
||||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
||||||
|
|||||||
@@ -28,15 +28,23 @@
|
|||||||
color: var(--text-secondary);
|
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;
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createNew {
|
||||||
|
flex: 1 1 auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin: var(--space-4);
|
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 var(--space-5);
|
padding: 0 var(--space-4);
|
||||||
border: var(--border-width) solid transparent;
|
border: var(--border-width) solid transparent;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -54,10 +62,42 @@
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Narrow pane → the CTA sheds its label and centres the "+" icon (the button stays
|
/* The expert accelerator: a quiet bordered ghost beside the primary. */
|
||||||
full-width, so it keeps a large target). The threshold sits comfortably above the
|
.createGhost {
|
||||||
216px pane minimum, so the full label is shown across the normal width range and
|
flex: 0 1 auto;
|
||||||
only collapses when the pane is dragged tight. */
|
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) {
|
@container (max-width: 250px) {
|
||||||
.createNewLabel {
|
.createNewLabel {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type Snippet,
|
type Snippet,
|
||||||
} from '@core/snippet';
|
} from '@core/snippet';
|
||||||
import { filterAndSortSnippets } from '@core/snippet-sort';
|
import { filterAndSortSnippets } from '@core/snippet-sort';
|
||||||
|
import { openModal } from '../modals/ModalCoordinator';
|
||||||
import { confirm } from '../stores/ConfirmStore';
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
||||||
@@ -251,21 +252,33 @@ export function SnippetLibrary() {
|
|||||||
<LibrarySettings />
|
<LibrarySettings />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create raises no toast: the new snippet opens in the editor, so the
|
{/* The creation surface (spec §02): the guided path is the primary — Build
|
||||||
result is already on-screen (spec §02; docs/architecture/10 → Toast
|
Chart opens the Chart Builder — and raw JSON stays one visible click away
|
||||||
copy). Delete/duplicate toast because the outcome isn't visible. */}
|
as a ghost action (NN/g #6 recognition / #7 expert accelerator; one
|
||||||
{/* Primary CTA. When the library pane is dragged narrow the label collapses
|
primary per surface — Carbon). Neither raises a toast: both outcomes
|
||||||
to just the "+" icon (a @container query) so it never wraps to two lines;
|
open on-screen (the builder, or the new snippet in the editor). When the
|
||||||
`aria-label` keeps the accessible name when the text is hidden. */}
|
pane is dragged narrow the labels collapse to icons (a @container
|
||||||
<button
|
query); `aria-label` keeps each accessible name. */}
|
||||||
className={styles.createNew}
|
<div className={styles.createRow}>
|
||||||
onClick={() => createSnippet()}
|
<button
|
||||||
aria-label="Create New Snippet"
|
className={styles.createNew}
|
||||||
title="Create New Snippet"
|
onClick={() => openModal('chartBuilder')}
|
||||||
>
|
aria-label="Build Chart"
|
||||||
<Icon name="add" />
|
title="Build Chart"
|
||||||
<span className={styles.createNewLabel}>Create New Snippet</span>
|
>
|
||||||
</button>
|
<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 controls, pinned above the list (spec §02; council
|
||||||
SEARCH + SORT). Search is an unlabelled Carbon active-search: a search
|
SEARCH + SORT). Search is an unlabelled Carbon active-search: a search
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ALL_VIEWS: ViewState[] = [
|
|||||||
{ kind: 'dataset', datasetId: 7 },
|
{ kind: 'dataset', datasetId: 7 },
|
||||||
{ kind: 'dataset-new' },
|
{ kind: 'dataset-new' },
|
||||||
{ kind: 'dataset-build', datasetId: 42 },
|
{ kind: 'dataset-build', datasetId: 42 },
|
||||||
|
{ kind: 'build' },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('serializeHash', () => {
|
describe('serializeHash', () => {
|
||||||
@@ -28,6 +29,7 @@ describe('serializeHash', () => {
|
|||||||
expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe(
|
expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe(
|
||||||
'#datasets/dataset-42/build',
|
'#datasets/dataset-42/build',
|
||||||
);
|
);
|
||||||
|
expect(serializeHash({ kind: 'build' })).toBe('#build');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ describe('parseHash', () => {
|
|||||||
kind: 'dataset-build',
|
kind: 'dataset-build',
|
||||||
datasetId: 42,
|
datasetId: 42,
|
||||||
});
|
});
|
||||||
|
expect(parseHash('#build')).toEqual({ kind: 'build' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates a missing leading "#"', () => {
|
it('tolerates a missing leading "#"', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* dataset → #datasets/dataset-<id> (id is a decimal number)
|
* dataset → #datasets/dataset-<id> (id is a decimal number)
|
||||||
* dataset-new → #datasets/new
|
* dataset-new → #datasets/new
|
||||||
* dataset-build → #datasets/dataset-<id>/build
|
* 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. */
|
/** The serialized view. Snippet id is opaque; dataset id is the numeric id. */
|
||||||
@@ -27,7 +28,8 @@ export type ViewState =
|
|||||||
| { kind: 'datasets' } // #datasets
|
| { kind: 'datasets' } // #datasets
|
||||||
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
|
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
|
||||||
| { kind: 'dataset-new' } // #datasets/new
|
| { 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`.
|
* 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);
|
const snippet = /^snippet-(.+)$/.exec(hash);
|
||||||
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
|
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
|
||||||
|
|
||||||
|
if (hash === 'build') return { kind: 'build' };
|
||||||
|
|
||||||
const parts = hash.split('/').filter(Boolean);
|
const parts = hash.split('/').filter(Boolean);
|
||||||
if (parts[0] === 'datasets') {
|
if (parts[0] === 'datasets') {
|
||||||
if (parts.length === 1) return { kind: 'datasets' };
|
if (parts.length === 1) return { kind: 'datasets' };
|
||||||
@@ -75,6 +79,8 @@ export function serializeHash(view: ViewState): string {
|
|||||||
return '#datasets/new';
|
return '#datasets/new';
|
||||||
case 'dataset-build':
|
case 'dataset-build':
|
||||||
return `#datasets/dataset-${view.datasetId}/build`;
|
return `#datasets/dataset-${view.datasetId}/build`;
|
||||||
|
case 'build':
|
||||||
|
return '#build';
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = view;
|
const _exhaustive: never = view;
|
||||||
return _exhaustive;
|
return _exhaustive;
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ function snapshotOf(name: ModalName | null): string | null {
|
|||||||
|
|
||||||
/** Open `name`, optionally with a sub-target (dataset id, source key). */
|
/** Open `name`, optionally with a sub-target (dataset id, source key). */
|
||||||
export function openModal(name: ModalName, arg?: string): void {
|
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);
|
useAppStore.getState().setActiveModal(name);
|
||||||
getModalConfig(name)?.init?.(arg);
|
getModalConfig(name)?.init?.(arg);
|
||||||
stateSnapshot = snapshotOf(name);
|
stateSnapshot = snapshotOf(name);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function deriveViewState(): ViewState {
|
|||||||
if (modal && getModalConfig(modal)?.isUrlNavigable) {
|
if (modal && getModalConfig(modal)?.isUrlNavigable) {
|
||||||
if (modal === 'chartBuilder') {
|
if (modal === 'chartBuilder') {
|
||||||
const datasetId = useChartBuilderStore.getState().datasetId;
|
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') {
|
if (modal === 'datasets') {
|
||||||
const ds = useDatasetStore.getState();
|
const ds = useDatasetStore.getState();
|
||||||
@@ -136,6 +136,13 @@ export function applyView(view: ViewState): void {
|
|||||||
app.setActiveModal('datasets');
|
app.setActiveModal('datasets');
|
||||||
useDatasetStore.getState().startCreate();
|
useDatasetStore.getState().startCreate();
|
||||||
return;
|
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 {
|
} finally {
|
||||||
applying = false;
|
applying = false;
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
getState: () => ({ name: useExtractStore.getState().name }),
|
getState: () => ({ name: useExtractStore.getState().name }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
|
// Opened from a dataset's "Build Chart" action (`arg` is its id) or from the
|
||||||
// the dataset and pre-populates a smart default config (§06). Applies on Create
|
// 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.
|
// (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
|
// 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).
|
// click outside this large surface shouldn't throw it away (Escape/× still close).
|
||||||
|
|||||||
@@ -370,3 +370,80 @@ describe('constant values (setChannelConstant — the Property model)', () => {
|
|||||||
expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
* that, plus the create-flow side effects (new snippet, toast, activate, close).
|
* 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
|
* `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".
|
* config. `init(null)` — the library's Build-Chart door, which opens without a
|
||||||
* The mark is sticky after open (changing a column does not re-derive it) so the
|
* target (spec §06 → Opening) — picks the most recently modified dataset; only
|
||||||
* user's choice is never overridden mid-edit.
|
* 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';
|
import { create } from 'zustand';
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
isChannelTypeAllowed,
|
isChannelTypeAllowed,
|
||||||
isColumnAllowedOnChannel,
|
isColumnAllowedOnChannel,
|
||||||
pruneEncodings,
|
pruneEncodings,
|
||||||
|
rebaseBuilderConfig,
|
||||||
supportsBin,
|
supportsBin,
|
||||||
supportsTimeUnit,
|
supportsTimeUnit,
|
||||||
validAggregateOps,
|
validAggregateOps,
|
||||||
@@ -81,6 +84,12 @@ export interface ChartBuilderState {
|
|||||||
rowCount: number | null;
|
rowCount: number | null;
|
||||||
/** The working configuration the preview and the produced spec read from. */
|
/** The working configuration the preview and the produced spec read from. */
|
||||||
config: BuilderConfig;
|
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,
|
* 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
|
* spec §06 → Encoding). Clicking an empty channel slot arms it; clicking a field in
|
||||||
@@ -89,8 +98,19 @@ export interface ChartBuilderState {
|
|||||||
*/
|
*/
|
||||||
activeChannel: ChannelName | null;
|
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;
|
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;
|
setMark: (mark: MarkType) => void;
|
||||||
/**
|
/**
|
||||||
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
|
* 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,
|
columns: EMPTY_COLUMNS,
|
||||||
rowCount: null,
|
rowCount: null,
|
||||||
config: EMPTY_CONFIG,
|
config: EMPTY_CONFIG,
|
||||||
|
initialConfig: EMPTY_CONFIG,
|
||||||
activeChannel: null,
|
activeChannel: null,
|
||||||
|
|
||||||
init: (datasetId) => {
|
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 =
|
const dataset =
|
||||||
datasetId === null
|
datasetId === null
|
||||||
? undefined
|
? all.reduce<(typeof all)[number] | undefined>(
|
||||||
: useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
|
(best, d) => (best === undefined || d.modified > best.modified ? d : best),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
: all.find((d) => d.id === datasetId);
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
set({
|
set({
|
||||||
datasetId: null,
|
datasetId: null,
|
||||||
columns: EMPTY_COLUMNS,
|
columns: EMPTY_COLUMNS,
|
||||||
rowCount: null,
|
rowCount: null,
|
||||||
config: EMPTY_CONFIG,
|
config: EMPTY_CONFIG,
|
||||||
|
initialConfig: EMPTY_CONFIG,
|
||||||
activeChannel: null,
|
activeChannel: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -242,11 +270,42 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
columnTypes: dataset.columnTypes,
|
columnTypes: dataset.columnTypes,
|
||||||
columnStats: dataset.columnStats,
|
columnStats: dataset.columnStats,
|
||||||
};
|
};
|
||||||
|
const config = defaultBuilderConfig(dataset.name, columns);
|
||||||
set({
|
set({
|
||||||
datasetId: dataset.id,
|
datasetId: dataset.id,
|
||||||
columns,
|
columns,
|
||||||
rowCount: dataset.rowCount,
|
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,
|
activeChannel: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -499,6 +558,7 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
columns: EMPTY_COLUMNS,
|
columns: EMPTY_COLUMNS,
|
||||||
rowCount: null,
|
rowCount: null,
|
||||||
config: EMPTY_CONFIG,
|
config: EMPTY_CONFIG,
|
||||||
|
initialConfig: EMPTY_CONFIG,
|
||||||
activeChannel: null,
|
activeChannel: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
calculatedFieldNames,
|
calculatedFieldNames,
|
||||||
effectiveColumns,
|
effectiveColumns,
|
||||||
pruneEncodings,
|
pruneEncodings,
|
||||||
|
rebaseBuilderConfig,
|
||||||
type BuilderCalculate,
|
type BuilderCalculate,
|
||||||
type BuilderColumns,
|
type BuilderColumns,
|
||||||
type BuilderConfig,
|
type BuilderConfig,
|
||||||
@@ -1290,3 +1291,91 @@ describe('pruneEncodings', () => {
|
|||||||
expect(pruneEncodings(config, columns)).toBe(config);
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1034,6 +1034,30 @@ export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): Bui
|
|||||||
return changed ? { ...config, encodings } : config;
|
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`). */
|
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
|
||||||
export type ChartSpec = Record<string, unknown>;
|
export type ChartSpec = Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user