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
+89
View File
@@ -28,6 +28,7 @@ import {
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
rebaseBuilderConfig,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
@@ -1290,3 +1291,91 @@ describe('pruneEncodings', () => {
expect(pruneEncodings(config, columns)).toBe(config);
});
});
describe('rebaseBuilderConfig (dataset switch)', () => {
// The new dataset shares `category` but lacks `value`/`when`/`flag`.
const newColumns: BuilderColumns = {
columns: ['category', 'profit'],
columnTypes: [
{ name: 'category', type: 'string' },
{ name: 'profit', type: 'number' },
],
};
it('keeps chart-level intent and same-name bindings, re-pointing the dataset', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
title: 'Revenue by region',
subtitle: 'FY26',
width: 400,
height: 200,
sort: 'descending',
stack: 'normalize',
encodings: { x: { field: 'category', type: 'nominal' } },
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.datasetName).toBe('New');
expect(out.mark).toBe('bar');
expect(out.title).toBe('Revenue by region');
expect(out.subtitle).toBe('FY26');
expect(out.width).toBe(400);
expect(out.height).toBe(200);
expect(out.sort).toBe('descending');
expect(out.stack).toBe('normalize');
expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
});
it('sheds encodings and predicate filters bound to columns the new dataset lacks', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
filters: [
filter({ id: 'f1', field: 'value', fieldType: 'quantitative', op: 'gt', value: '0' }),
filter({ id: 'f2', field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' }),
],
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
expect(out.encodings.y).toBeNull();
expect(out.filters?.map((f) => f.id)).toEqual(['f2']);
});
it('keeps expression filters, constants, count mappings, and calculated-field bindings', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
encodings: {
x: { field: 'ratio', type: 'quantitative' }, // a calculated field travels along
y: { type: 'quantitative', aggregate: 'count' }, // field-less count
color: { value: '#ff0000', type: 'nominal' }, // constant — no column binding
},
calculates: [calc({ as: 'ratio', expr: 'datum.profit * 2' })],
filters: [filter({ id: 'fx', mode: 'expression', expr: 'datum.value > 0' })],
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.encodings.x).toEqual({ field: 'ratio', type: 'quantitative' });
expect(out.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
expect(out.encodings.color).toEqual({ value: '#ff0000', type: 'nominal' });
expect(out.calculates).toEqual(config.calculates);
expect(out.filters).toEqual(config.filters);
});
it('a same-schema dataset keeps the whole config (only the name changes)', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'line',
encodings: {
x: { field: 'when', type: 'temporal' },
y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
},
filters: [filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' })],
};
const out = rebaseBuilderConfig(config, 'New', columns);
expect(out).toEqual({ ...config, datasetName: 'New' });
});
});
+24
View File
@@ -1034,6 +1034,30 @@ export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): Bui
return changed ? { ...config, encodings } : config;
}
/**
* Re-point an in-progress config at a different dataset (spec §06 → Dataset
* picker). Chart-level intent survives the switch — mark, title/subtitle,
* explicit size, sort/stack, calculated fields, and expression filters (their
* `datum` references are surfaced by the unknown-field feedback, not dropped) —
* while anything bound to a column the new dataset doesn't have is shed:
* encodings via `pruneEncodings`, predicate filters by field lookup. With a
* same-schema dataset (the common switch: a fresher version of the same data)
* everything survives verbatim.
*/
export function rebaseBuilderConfig(
config: BuilderConfig,
datasetName: string,
columns: BuilderColumns,
): BuilderConfig {
const available = new Set(effectiveColumns(columns, config.calculates).columns);
const filters = (config.filters ?? []).filter(
(f) => f.mode === 'expression' || (f.field !== undefined && available.has(f.field)),
);
const rebased: BuilderConfig = { ...config, datasetName };
if (config.filters !== undefined) rebased.filters = filters;
return pruneEncodings(rebased, columns);
}
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>;