Chart builder: drop unparseable expressions mid-edit, polite glyphed inline feedback

This commit is contained in:
2026-06-11 16:08:03 +03:00
parent 05df0cd7d3
commit d666599a58
9 changed files with 157 additions and 24 deletions
+23
View File
@@ -974,6 +974,29 @@ describe('buildTransforms (predicate coercion + shape)', () => {
};
expect(buildTransforms(config)).toEqual([]);
});
it('drops a syntactically-invalid expression filter (mid-edit preview resilience)', () => {
const t = buildTransforms(
withFilters(
filter({ mode: 'expression', expr: 'datum.value *' }), // half-typed → unparseable
filter({ id: 'f2', mode: 'expression', expr: 'datum.value > 0' }), // valid stays
),
);
expect(t).toEqual([{ filter: 'datum.value > 0' }]);
});
it('drops a calculated field whose expression does not parse', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [
calc({ as: 'bad', expr: 'datum.a +' }),
calc({ id: 'c2', as: 'ok', expr: 'datum.a' }),
],
};
expect(buildTransforms(config)).toEqual([{ calculate: 'datum.a', as: 'ok' }]);
});
});
describe('buildChartSpec — transform integration', () => {
+13 -6
View File
@@ -26,6 +26,7 @@ import type { ColumnStats } from './profile';
import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
/** The five mark types the builder offers, in selector order (spec §06). */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const;
@@ -835,25 +836,31 @@ function predicateObject(filter: BuilderFilter): Record<string, unknown> | null
function filterTransformObject(filter: BuilderFilter): Record<string, unknown> | null {
if (filter.mode === 'expression') {
const expr = (filter.expr ?? '').trim();
return expr === '' ? null : { filter: expr };
// A syntactically-invalid expression is dropped like an empty one: a half-typed
// `datum.x *` must not reach the renderer and blank the preview mid-edit — the
// inline feedback already flags it. (Same mid-edit resilience as a partial predicate.)
return expr === '' || !validateExpression(expr).valid ? null : { filter: expr };
}
const predicate = predicateObject(filter);
return predicate ? { filter: predicate } : null;
}
/** One calculate's `{ calculate, as }` transform entry, or `null` when incomplete. */
/** One calculate's `{ calculate, as }` transform entry, or `null` when incomplete or unparseable. */
function calculateTransformObject(calc: BuilderCalculate): Record<string, unknown> | null {
const expr = calc.expr.trim();
const as = calc.as.trim();
return expr === '' || as === '' ? null : { calculate: expr, as };
return expr === '' || as === '' || !validateExpression(expr).valid
? null
: { calculate: expr, as };
}
/**
* The complete top-level `transform` array for a configuration: every calculated
* field first (so filters and encodings can reference the derived columns), then
* every filter, each in the user's list order. Incomplete entries (a half-typed
* filter, an unnamed calculate) are dropped so a configuration mid-edit still
* produces a renderable spec. An empty result means no `transform` key is emitted.
* every filter, each in the user's list order. Incomplete or unparseable entries (a
* half-typed predicate, an unnamed calculate, an expression that doesn't parse) are
* dropped so a configuration mid-edit still produces a renderable spec. An empty
* result means no `transform` key is emitted.
*/
export function buildTransforms(config: BuilderConfig): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];