Chart builder: filter/calculate transforms, data preview, and inline expression validation

This commit is contained in:
2026-06-11 13:01:23 +03:00
parent 1791ee9f8d
commit 05df0cd7d3
13 changed files with 1919 additions and 27 deletions
@@ -43,6 +43,331 @@
color: var(--text);
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
.dataSection {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.transformGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.transformGroupHead {
display: flex;
align-items: center;
justify-content: space-between;
}
/* "Add filter" / "Add field" — a low-emphasis ghost action, like Swap X/Y. */
.addRow {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: var(--accent);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.addRow:hover {
background: var(--layer-01);
}
.addRow:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* A filter / calculated-field row, bordered like a channel block for visual rhyme. */
.transformBlock {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.transformBlockTop {
display: flex;
align-items: center;
gap: var(--space-2);
}
.filterField {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.filterPredicate {
display: flex;
align-items: center;
gap: var(--space-2);
}
.filterPredicate .mini {
flex: none;
}
.valueInput {
flex: 1;
min-width: 0;
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.rangeDash {
flex: none;
color: var(--text-placeholder);
}
/* Expression inputs (filter expression / calculate) — monospace, fill the row. */
.exprInput {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.calcName {
flex: 0 1 110px;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.calcEquals {
flex: none;
color: var(--text-placeholder);
font-family: var(--font-mono);
}
.filterField:focus-visible,
.calcName:focus-visible,
.valueInput:focus-visible,
.exprInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.exprInput[aria-invalid='true'] {
border-color: var(--support-error);
}
/* Remove-row × — icon-only, quiet until hovered. */
.removeRow {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-placeholder);
cursor: pointer;
border-radius: var(--radius);
}
.removeRow:hover {
background: var(--layer-01);
color: var(--text);
}
.removeRow:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* Expression escape-hatch toggle — a low-emphasis link, like the hint fixes. */
.modeToggle {
align-self: flex-start;
border: none;
background: transparent;
color: var(--accent);
font: inherit;
font-size: 11px;
cursor: pointer;
padding: 2px var(--space-1);
border-radius: var(--radius);
}
.modeToggle:hover {
background: var(--layer-01);
}
.modeToggle:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.exprError,
.exprWarn {
margin: 0;
font-size: 11px;
line-height: 1.4;
}
.exprError {
color: var(--support-error);
}
.exprWarn {
color: var(--support-warning-fg);
}
/* Contextual pointer to the Vega expression docs (shown when an expression exists). */
.exprHelp {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--text-secondary);
}
.exprHelpLink {
color: var(--accent);
text-decoration: none;
border-radius: var(--radius);
}
.exprHelpLink:hover {
text-decoration: underline;
}
.exprHelpLink:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* Row preview disclosure (1D). */
.dataPreview {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.previewToggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
align-self: flex-start;
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.previewToggle:hover {
background: var(--layer-01);
}
.previewToggle:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.previewCaret {
font-size: 10px;
color: var(--text-placeholder);
}
.previewMeta {
color: var(--text-placeholder);
}
.previewTableWrap {
max-height: 200px;
overflow: auto;
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.previewTableWrap:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.previewTable {
border-collapse: collapse;
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
}
.previewTable th,
.previewTable td {
text-align: left;
padding: var(--space-1) var(--space-2);
border-bottom: var(--border-width) solid var(--border);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.previewTable thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--layer-01);
}
.previewColName {
font-weight: 600;
color: var(--text-secondary);
}
.previewColType {
color: var(--text-placeholder);
font-weight: 400;
}
.previewTable tbody tr:last-child td {
border-bottom: none;
}
.previewEmptyNote {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
}
.field {
display: flex;
flex-direction: column;
@@ -162,4 +162,131 @@ describe('ChartBuilderModal', () => {
});
expect(container.textContent).toContain('No dataset loaded');
});
test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
vi.useFakeTimers();
const { renderSpec } = await import('../services/chart-renderer');
vi.mocked(renderSpec).mockClear();
const ds = createDataset({
name: 'Sales',
data: [
{ region: 'N', revenue: 100 },
{ region: 'S', revenue: 50 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.addFilter();
const fid = useChartBuilderStore.getState().config.filters![0].id;
store.setFilterField(fid, 'revenue');
store.updateFilter(fid, { op: 'gt', value: '60' });
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
});
expect(container.querySelector('select[aria-label="Filter operator"]')).toBeTruthy();
const calls = vi.mocked(renderSpec).mock.calls;
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
vi.useRealTimers();
});
test('the data preview discloses the dataset rows on demand (1D)', async () => {
const ds = createDataset({
name: 'Sales',
data: [{ region: 'North', revenue: 100 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const toggle = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Preview rows'),
);
expect(toggle).toBeTruthy();
expect(container.querySelector('table')).toBeNull(); // collapsed by default
await act(async () => {
toggle!.click();
await Promise.resolve();
});
expect(container.querySelector('table')).toBeTruthy();
expect(container.textContent).toContain('region');
expect(container.textContent).toContain('North');
});
test('an invalid expression-mode filter surfaces an inline parser error (1E)', async () => {
const ds = createDataset({
name: 'S',
data: [{ a: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.addFilter();
const fid = useChartBuilderStore.getState().config.filters![0].id;
store.setFilterMode(fid, 'expression');
store.updateFilter(fid, { expr: 'datum.a *' });
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toMatch(/nexpected|Invalid/);
});
test('the Vega expression reference shows only when an expression is in play (1E)', async () => {
const ds = createDataset({
name: 'S',
data: [{ a: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const refLink = () =>
Array.from(container.querySelectorAll('a')).find((a) =>
a.textContent?.includes('Vega expression'),
);
expect(refLink()).toBeUndefined(); // no expression yet → no reference link
await act(async () => {
useChartBuilderStore.getState().addCalculate(); // a calculated field is an expression
await Promise.resolve();
});
expect(refLink()).toBeDefined();
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
});
});
+420 -1
View File
@@ -24,6 +24,8 @@ import {
TIME_UNITS,
builderWarnings,
defaultFieldType,
effectiveColumns,
filterOpArity,
isBuilderConfigValid,
isChannelTypeAllowed,
supportsAggregate,
@@ -32,14 +34,21 @@ import {
supportsStack,
supportsTimeUnit,
validFieldTypes,
validFilterOps,
type BuilderCalculate,
type AggregateOp,
type BuilderColumns,
type BuilderFilter,
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
type FieldType,
type FilterOp,
type MarkType,
type TimeUnit,
} from '@core/chart-builder';
import { referencedFields, validateExpression } from '@core/expr-validate';
import { tabularRows } from '@core/dataset';
import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
@@ -131,6 +140,57 @@ const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
hours: 'Hour',
};
/** Readable labels for the filter operators, phrased to read as "<field> <op> <value>". */
const FILTER_OP_LABELS: Record<FilterOp, string> = {
equal: 'is',
notEqual: 'is not',
lt: '<',
lte: '≤',
gt: '>',
gte: '≥',
range: 'is between',
oneOf: 'is one of',
};
/** Rows shown in the builder's data-preview table before truncating (1D, spec §06). */
const PREVIEW_ROW_LIMIT = 50;
/**
* The Vega expression-language reference — both expression inputs (a filter's
* expression mode, a calculated field) compile to a raw Vega expression, so this is
* the precise vocabulary. Surfaced contextually (only when an expression is in play),
* external so it falls outside offline scope, opened in a new tab.
*/
const VEGA_EXPRESSION_DOCS_URL = 'https://vega.github.io/vega/docs/expressions/';
/** One preview cell's text: blank for empty, the string as-is, else JSON. */
function cellText(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
/** A safe `datum` accessor for a column name (dot for identifiers, bracket otherwise). */
function datumRef(name: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? `datum.${name}` : `datum[${JSON.stringify(name)}]`;
}
/**
* An example expression seeded as the input placeholder, drawn from the dataset's own
* columns — discovery for writing Vega expressions without an autocomplete popup. A
* filter example reads as a predicate; a calculate example as a derived value.
*/
function exprPlaceholder(columns: BuilderColumns, kind: 'filter' | 'calc'): string {
const numeric = columns.columnTypes.find((c) => c.type === 'number')?.name;
const anyField = columns.columns[0];
if (kind === 'filter') {
if (numeric) return `${datumRef(numeric)} > 0`;
return anyField ? `${datumRef(anyField)} != null` : 'datum.value > 0';
}
if (numeric) return `${datumRef(numeric)} * 2`;
return anyField ? datumRef(anyField) : 'datum.a + datum.b';
}
/** A compact type indicator for a column option (# / date / bool / text). */
function typeBadge(type: ColumnType): string {
switch (type) {
@@ -156,7 +216,14 @@ function isCount(mapping: ChannelMapping | null): boolean {
}
function ChannelBlock({ channel }: { channel: ChannelName }) {
const columns = useChartBuilderStore((s) => s.columns);
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
// Effective columns = the dataset's columns plus any calculated fields, so a derived
// field is selectable on a channel like any real column.
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
@@ -277,6 +344,356 @@ function ChannelBlock({ channel }: { channel: ChannelName }) {
);
}
/**
* Inline feedback for an expression input (filter expression / calculated field):
* a parse error (assertive) takes priority, else a soft warning for `datum.<field>`
* references that don't match a known column — a typo guard before the chart renders
* empty (1E). Nothing renders for a valid, fully-resolved expression.
*/
function ExprFeedback({ expr, columns }: { expr: string; columns: BuilderColumns }) {
const feedback = useMemo(() => {
const validation = validateExpression(expr);
if (!validation.valid) {
return { kind: 'error' as const, text: validation.error ?? 'Invalid expression.' };
}
const unknown = referencedFields(expr).filter((f) => !columns.columns.includes(f));
if (unknown.length > 0) {
const plural = unknown.length > 1 ? 's' : '';
return { kind: 'warn' as const, text: `Unknown field${plural}: ${unknown.join(', ')}` };
}
return null;
}, [expr, columns]);
if (!feedback) return null;
return feedback.kind === 'error' ? (
<p className={styles.exprError} role="alert">
{feedback.text}
</p>
) : (
<p className={styles.exprWarn} role="status">
{feedback.text}
</p>
);
}
/**
* A contextual pointer to the Vega expression vocabulary, shown only when an
* expression input is in play (a calculated field, or a filter in expression mode) —
* the place the user needs to know what functions/operators exist.
*/
function ExprHelp() {
return (
<p className={styles.exprHelp}>
Filter expressions and calculated fields use the{' '}
<a
className={styles.exprHelpLink}
href={VEGA_EXPRESSION_DOCS_URL}
target="_blank"
rel="noreferrer"
>
Vega expression language
<span aria-hidden="true"> </span>
<span className="visually-hidden"> (opens in a new tab)</span>
</a>
.
</p>
);
}
/** One filter row — a guarded `field op value` predicate, or a raw expression. */
function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: BuilderColumns }) {
const setFilterField = useChartBuilderStore((s) => s.setFilterField);
const updateFilter = useChartBuilderStore((s) => s.updateFilter);
const setFilterMode = useChartBuilderStore((s) => s.setFilterMode);
const removeFilter = useChartBuilderStore((s) => s.removeFilter);
const expressionMode = filter.mode === 'expression';
const fieldType = filter.fieldType ?? 'nominal';
const op = filter.op ?? 'equal';
const arity = filterOpArity(op);
const hasColumns = columns.columns.length > 0;
return (
<div className={styles.transformBlock}>
<div className={styles.transformBlockTop}>
{expressionMode ? (
<input
className={styles.exprInput}
aria-label="Filter expression"
placeholder={exprPlaceholder(columns, 'filter')}
value={filter.expr ?? ''}
aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined}
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
/>
) : (
<select
className={styles.filterField}
aria-label="Filter field"
value={filter.field ?? ''}
onChange={(e) => setFilterField(filter.id, e.target.value)}
>
{!filter.field && <option value="">Choose a field</option>}
{columns.columns.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
<button
type="button"
className={styles.removeRow}
aria-label="Remove filter"
onClick={() => removeFilter(filter.id)}
>
<Icon name="close" />
</button>
</div>
{!expressionMode && (
<div className={styles.filterPredicate}>
<select
className={styles.mini}
aria-label="Filter operator"
value={op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
>
{validFilterOps(fieldType).map((o) => (
<option key={o} value={o}>
{FILTER_OP_LABELS[o]}
</option>
))}
</select>
{arity === 'range' ? (
<>
<input
className={styles.valueInput}
aria-label="Lower bound"
placeholder="min"
value={filter.value ?? ''}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
/>
<span className={styles.rangeDash} aria-hidden="true">
</span>
<input
className={styles.valueInput}
aria-label="Upper bound"
placeholder="max"
value={filter.value2 ?? ''}
onChange={(e) => updateFilter(filter.id, { value2: e.target.value })}
/>
</>
) : (
<input
className={styles.valueInput}
aria-label={arity === 'list' ? 'Values, comma-separated' : 'Filter value'}
placeholder={arity === 'list' ? 'A, B, C' : 'value'}
value={filter.value ?? ''}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
/>
)}
</div>
)}
{expressionMode && <ExprFeedback expr={filter.expr ?? ''} columns={columns} />}
{hasColumns && (
<button
type="button"
className={styles.modeToggle}
onClick={() => setFilterMode(filter.id, expressionMode ? 'predicate' : 'expression')}
>
{expressionMode ? 'Use the field picker' : 'Write an expression'}
</button>
)}
</div>
);
}
/** One calculated-field row — a name and a Vega expression producing a new column. */
function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) {
const updateCalculate = useChartBuilderStore((s) => s.updateCalculate);
const removeCalculate = useChartBuilderStore((s) => s.removeCalculate);
return (
<div className={styles.transformBlock}>
<div className={styles.transformBlockTop}>
<input
className={styles.calcName}
aria-label="New field name"
placeholder="new field"
value={calc.as}
onChange={(e) => updateCalculate(calc.id, { as: e.target.value })}
/>
<span className={styles.calcEquals} aria-hidden="true">
=
</span>
<input
className={styles.exprInput}
aria-label="Calculated field expression"
placeholder={exprPlaceholder(columns, 'calc')}
value={calc.expr}
aria-invalid={!validateExpression(calc.expr).valid || undefined}
onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
/>
<button
type="button"
className={styles.removeRow}
aria-label="Remove calculated field"
onClick={() => removeCalculate(calc.id)}
>
<Icon name="close" />
</button>
</div>
<ExprFeedback expr={calc.expr} columns={columns} />
</div>
);
}
/**
* A collapsible, read-only sample of the dataset's actual rows with a per-column type
* chip in each header (1D) — lets the user sanity-check inferred types before building,
* exactly when inference is most likely to surprise. Shows the base dataset columns
* (calculated fields don't exist in the raw rows). Non-tabular payloads have no rows.
*/
function DataPreview() {
const datasetId = useChartBuilderStore((s) => s.datasetId);
const baseColumns = useChartBuilderStore((s) => s.columns);
const dataset = useDatasetStore((s) => s.datasets.find((d) => d.id === datasetId) ?? null);
const [open, setOpen] = useState(false);
const rows = useMemo(
() => (dataset ? tabularRows(dataset.data, dataset.format, PREVIEW_ROW_LIMIT) : null),
[dataset],
);
if (!dataset) return null;
const typeOf = (name: string): ColumnType =>
baseColumns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
return (
<div className={styles.dataPreview}>
<button
type="button"
className={styles.previewToggle}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={styles.previewCaret} aria-hidden="true">
{open ? '▾' : '▸'}
</span>
Preview rows
{dataset.rowCount != null && (
<span className={styles.previewMeta}>
{dataset.rowCount.toLocaleString()} rows · {dataset.columnCount} cols
</span>
)}
</button>
{open &&
(rows ? (
<div
className={styles.previewTableWrap}
tabIndex={0}
role="group"
aria-label="Data preview"
>
<table className={styles.previewTable}>
<thead>
<tr>
{baseColumns.columns.map((col) => (
<th key={col} scope="col">
<span className={styles.previewColName}>{col}</span>{' '}
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{baseColumns.columns.map((col) => (
<td key={col}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
))}
{open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && (
<p className={styles.previewEmptyNote}>
Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows.
</p>
)}
</div>
);
}
/**
* The "Data" section at the top of the config pane: row filters and calculated fields
* (the top-level transforms, 1C) plus a collapsible row preview (1D) — "here are your
* rows, shape them, then encode them". Calculated fields appear in the channel
* dropdowns below via the effective-columns derivation.
*/
function DataSection() {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const filters = useChartBuilderStore((s) => s.config.filters);
const addFilter = useChartBuilderStore((s) => s.addFilter);
const addCalculate = useChartBuilderStore((s) => s.addCalculate);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
// The expression reference is shown only when an expression input exists — a
// calculated field, or a filter switched to expression mode.
const hasExpression =
(filters ?? []).some((f) => f.mode === 'expression') || (calculates ?? []).length > 0;
return (
<section className={styles.dataSection} aria-label="Data">
<span className={styles.fieldLabel}>Data</span>
{/* The source rows come first, so they read as the input — distinct from the
filters/calculated fields below, which shape what the chart actually draws. */}
<DataPreview />
<div className={styles.transformGroup}>
<div className={styles.transformGroupHead}>
<span className={styles.miniLabel}>Filters</span>
<button type="button" className={styles.addRow} onClick={addFilter}>
<Icon name="add" /> Add filter
</button>
</div>
{(filters ?? []).map((f) => (
<FilterRow key={f.id} filter={f} columns={columns} />
))}
</div>
<div className={styles.transformGroup}>
<div className={styles.transformGroupHead}>
<span className={styles.miniLabel}>Calculated fields</span>
<button type="button" className={styles.addRow} onClick={addCalculate}>
<Icon name="add" /> Add field
</button>
</div>
{(calculates ?? []).map((c) => (
<CalculateRow key={c.id} calc={c} columns={columns} />
))}
</div>
{hasExpression && <ExprHelp />}
</section>
);
}
/** Sort control values: 'none' maps to an unsorted config. */
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
{ value: 'none', label: 'None' },
@@ -488,6 +905,8 @@ export function ChartBuilderModal() {
Building from <strong>{datasetName}</strong>
</p>
<DataSection />
<div className={styles.field}>
<span className={styles.fieldLabel}>Mark</span>
<SegmentedControl
+89
View File
@@ -140,6 +140,95 @@ describe('applyWarningFix', () => {
});
});
describe('filters (1C)', () => {
test('addFilter seeds a predicate on the first column with its derived type', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const f = cb().config.filters?.[0];
expect(f).toMatchObject({
mode: 'predicate',
field: 'region',
fieldType: 'nominal',
op: 'equal',
});
});
test('setFilterField re-derives the field type and clamps an out-of-range operator', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const fid = cb().config.filters![0].id;
// Point at the measure, choose an ordering op, then point back at the category:
cb().setFilterField(fid, 'revenue');
cb().updateFilter(fid, { op: 'gt', value: '3' });
expect(cb().config.filters![0]).toMatchObject({ fieldType: 'quantitative', op: 'gt' });
cb().setFilterField(fid, 'region'); // gt is invalid on a category → resets to equal
expect(cb().config.filters![0]).toMatchObject({
field: 'region',
fieldType: 'nominal',
op: 'equal',
});
});
test('removeFilter drops the row', () => {
const id = seedDataset('S', [{ region: 'N' }]);
cb().init(id);
cb().addFilter();
cb().addFilter();
const first = cb().config.filters![0].id;
cb().removeFilter(first);
expect(cb().config.filters).toHaveLength(1);
expect(cb().config.filters![0].id).not.toBe(first);
});
test('setFilterMode toggles to expression and back, seeding a field on return', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const fid = cb().config.filters![0].id;
cb().setFilterMode(fid, 'expression');
expect(cb().config.filters![0].mode).toBe('expression');
cb().setFilterMode(fid, 'predicate');
expect(cb().config.filters![0]).toMatchObject({ mode: 'predicate', field: 'region' });
});
});
describe('calculated fields (1C)', () => {
test('a calculated field becomes selectable on a channel, defaulting to quantitative', () => {
const id = seedDataset('S', [{ a: 1, b: 2 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
cb().setChannelColumn('y', 'total');
expect(cb().config.encodings.y).toEqual({ field: 'total', type: 'quantitative' });
});
test('removing a calculated field clears any channel that referenced it', () => {
const id = seedDataset('S', [{ a: 1, b: 2 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
cb().setChannelColumn('y', 'total');
cb().removeCalculate(cid);
expect(cb().config.calculates).toHaveLength(0);
expect(cb().config.encodings.y).toBeNull(); // pruned — its field no longer exists
});
test('renaming a referenced calculated field prunes the now-dangling channel', () => {
const id = seedDataset('S', [{ a: 1 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'doubled', expr: 'datum.a * 2' });
cb().setChannelColumn('x', 'doubled');
cb().updateCalculate(cid, { as: 'tripled' }); // the channel still points at "doubled"
expect(cb().config.encodings.x).toBeNull();
});
});
describe('createSnippet', () => {
test('builds a linked snippet, activates it, and resets the builder', () => {
const id = seedDataset('Sales', [
+142 -3
View File
@@ -18,20 +18,26 @@ import {
buildSnippetSpecText,
defaultBuilderConfig,
defaultFieldType,
effectiveColumns,
generateChartName,
isBuilderConfigValid,
isChannelTypeAllowed,
pruneEncodings,
supportsAggregate,
supportsBin,
supportsTimeUnit,
validFieldTypes,
validFilterOps,
type AggregateOp,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
type FieldType,
type FilterMode,
type MarkType,
type SortOrder,
type StackMode,
@@ -54,6 +60,15 @@ const EMPTY_COLUMNS: BuilderColumns = { columns: [], columnTypes: [] };
*/
export const COUNT_FIELD = '\u0000count';
/**
* Monotonic id source for filter / calculated-field list rows. Ids are stable React
* keys and edit handles only — they never reach the produced spec — so a plain
* session counter is enough (no need for crypto/uuid), and it keeps the rows
* order-stable as the user adds and removes them.
*/
let transformSeq = 0;
const nextTransformId = (prefix: 'f' | 'c'): string => `${prefix}${++transformSeq}`;
export interface ChartBuilderState {
/** The dataset being built from, or null when none is loaded. */
datasetId: number | null;
@@ -87,6 +102,24 @@ export interface ChartBuilderState {
setSort: (sort: SortOrder | undefined) => void;
/** Stacking mode for bar/area + a colour series; `undefined` = Vega-Lite default. */
setStack: (stack: StackMode | undefined) => void;
/** Append a new, empty predicate filter (defaults to the first column, equals). */
addFilter: () => void;
/** Patch one filter row by id (op/value/value2/expr/mode). */
updateFilter: (id: string, patch: Partial<Omit<BuilderFilter, 'id'>>) => void;
/** Re-point a filter to a column: derives its field type and clamps the operator. */
setFilterField: (id: string, field: string) => void;
/** Switch a filter between the guarded predicate shelf and a raw expression. */
setFilterMode: (id: string, mode: FilterMode) => void;
/** Remove a filter row by id. */
removeFilter: (id: string) => void;
/** Append a new, empty calculated field. */
addCalculate: () => void;
/** Patch one calculated field by id (expr / as); prunes any now-dangling encoding. */
updateCalculate: (id: string, patch: Partial<Omit<BuilderCalculate, 'id'>>) => void;
/** Remove a calculated field by id; clears any channel that referenced it. */
removeCalculate: (id: string) => void;
setWidth: (width: number | undefined) => void;
setHeight: (height: number | undefined) => void;
/** Build the spec, create + activate a linked snippet, toast, and close. */
@@ -99,6 +132,11 @@ function columnType(columns: BuilderColumns, name: string): ColumnType {
return columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
}
/** The dataset columns plus the config's calculated fields (what the dropdowns offer). */
function effCols(s: ChartBuilderState): BuilderColumns {
return effectiveColumns(s.columns, s.config.calculates);
}
/** Replace one channel's mapping, returning the new `{ config }` state slice. */
function updateEncoding(
s: ChartBuilderState,
@@ -150,10 +188,11 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
// Default to the column's natural type, but if that type isn't allowed on
// this channel (e.g. a category on Size), fall back to the first valid type
// that is — the UI also disables unsuitable columns, this is the guard.
const valid = validFieldTypes(columnType(s.columns, columnName));
// Effective columns include calculated fields (which default to numeric).
const colType = columnType(effCols(s), columnName);
const valid = validFieldTypes(colType);
const type =
valid.find((t) => isChannelTypeAllowed(channel, t)) ??
defaultFieldType(columnType(s.columns, columnName));
valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType);
mapping = { field: columnName, type }; // a fresh mapping clears prior transforms
}
return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } };
@@ -209,6 +248,106 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
setSort: (sort) => set((s) => ({ config: { ...s.config, sort } })),
setStack: (stack) => set((s) => ({ config: { ...s.config, stack } })),
addFilter: () =>
set((s) => {
// Seed the new row on the first available column so it is immediately usable;
// an unmapped dataset (no columns) yields an expression-mode row instead.
const first = effCols(s).columns[0];
const filter: BuilderFilter = first
? {
id: nextTransformId('f'),
mode: 'predicate',
field: first,
fieldType: defaultFieldType(columnType(effCols(s), first)),
op: 'equal',
value: '',
}
: { id: nextTransformId('f'), mode: 'expression', expr: '' };
return { config: { ...s.config, filters: [...(s.config.filters ?? []), filter] } };
}),
updateFilter: (id, patch) =>
set((s) => ({
config: {
...s.config,
filters: (s.config.filters ?? []).map((f) => (f.id === id ? { ...f, ...patch } : f)),
},
})),
setFilterField: (id, field) =>
set((s) => {
const fieldType = defaultFieldType(columnType(effCols(s), field));
return {
config: {
...s.config,
filters: (s.config.filters ?? []).map((f) => {
if (f.id !== id) return f;
// Re-point the field and its type; keep the operator only if it is still
// valid for the new type (a measure op on a category resets to equals).
const op = f.op && validFilterOps(fieldType).includes(f.op) ? f.op : 'equal';
return { ...f, field, fieldType, op };
}),
},
};
}),
setFilterMode: (id, mode) =>
set((s) => {
const first = effCols(s).columns[0];
return {
config: {
...s.config,
filters: (s.config.filters ?? []).map((f) => {
if (f.id !== id) return f;
// Switching to the predicate shelf without a field yet (e.g. the row was
// born in expression mode) seeds the first column so it's usable at once.
if (mode === 'predicate' && !f.field && first) {
return {
...f,
mode,
field: first,
fieldType: defaultFieldType(columnType(effCols(s), first)),
op: f.op ?? 'equal',
};
}
return { ...f, mode };
}),
},
};
}),
removeFilter: (id) =>
set((s) => ({
config: { ...s.config, filters: (s.config.filters ?? []).filter((f) => f.id !== id) },
})),
addCalculate: () =>
set((s) => ({
config: {
...s.config,
calculates: [
...(s.config.calculates ?? []),
{ id: nextTransformId('c'), expr: '', as: '' },
],
},
})),
updateCalculate: (id, patch) =>
set((s) => {
const calculates = (s.config.calculates ?? []).map((c) =>
c.id === id ? { ...c, ...patch } : c,
);
// A rename (changed `as`) can orphan a channel that mapped the old name; prune
// any encoding whose field no longer exists among the effective columns.
return { config: pruneEncodings({ ...s.config, calculates }, s.columns) };
}),
removeCalculate: (id) =>
set((s) => {
const calculates = (s.config.calculates ?? []).filter((c) => c.id !== id);
return { config: pruneEncodings({ ...s.config, calculates }, s.columns) };
}),
swapXY: () =>
set((s) => ({
config: {