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
@@ -368,6 +368,19 @@ bullet is the contract; cite it, not the source.)_
other shows the text visually with no live role. Two live regions would announce the same other shows the text visually with no live role. Two live regions would announce the same
message twice. message twice.
**Resolved — inline _live_ validation feedback is polite, glyphed, and field-linked.** A
validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a
calculated field, a filter in expression mode) is **not** an assertive `alert`: that would
interrupt on each character (APG _Alert_ "avoid frequent interruptions"; WCAG 2.2.4). It is a
**polite `role="status"`** line, and severity reads from a **status glyph** (round error /
triangle warning, arch 09 §5.2) **plus** colour — never colour alone (§3; WCAG 1.4.1), exactly
like the guidance warnings above it. The owning `<input>` carries `aria-invalid` for the state
and `aria-describedby` pointing at the message node so the text is available on focus, not only
when it changes (GOV.UK _error-message_ field association). Contrast the editor's **render**
error, which is a discrete, post-debounce result and stays the single `alert` of the rule
above. _(Consulted via /council → WAI-ARIA APG Alert, GOV.UK error-message, NN/g #9. This
bullet is the contract; cite it, not the source.)_
**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, Chart **Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, Chart
Builder) is a **passive** `dialog-modal`: dismissed by the close Builder) is a **passive** `dialog-modal`: dismissed by the close
button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so
+28 -1
View File
@@ -25,7 +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-11** — **1C + 1D + 1E shipped (the Data section). Up next: Phase 2 (2A value-or-field channels, then 2B field shelf).** - **2026-06-11 (later)** — **Owed debts on the Data section closed. Up next: Phase 2 (2A value-or-field channels, then 2B field shelf).**
- **Council pass** on the new error/disclosure copy (the previously-deferred auto-fire
surface). Three a11y conformance gaps against `architecture/10` were fixed: the inline
expression feedback now carries a **status glyph** (round error / triangle warning), not
colour alone (§3, WCAG 1.4.1); the parse error is a **polite `role="status"`**, not a
per-keystroke assertive alert (APG Alert / WCAG 2.2.4); and the message is linked to its
input via **`aria-describedby`** (GOV.UK error-message). Unknown-field copy clarified to
"…— not a column in this dataset." Resolution recorded in `architecture/10` §5.
- **Manual/visual pass** run via a headless-Chrome (Playwright) walk-through of the live
builder — filter shelf (predicate + `is between`), field↔expression toggle, expression
error/unknown-field glyphs (filter _and_ calc inputs), calculated field, data-preview
table with type chips, smart-default chart. All surfaces render as intended.
- **Bug found + fixed (mid-edit preview resilience).** `filterTransformObject` /
`calculateTransformObject` emitted **any non-empty** expression — including a half-typed,
unparseable one — so the preview blanked with a raw render error while the user typed.
They now **drop a syntactically-invalid expression** like an empty/incomplete entry
(guarded by core `validateExpression`), matching `buildTransforms`' own "a config
mid-edit still renders" contract; the inline feedback still flags the typo. Pure core,
tested (+2 cases). Visually confirmed: an invalid filter/calc now keeps the last-good
chart instead of breaking the preview.
- **Verified:** `typecheck` + `test` (741 passing) + `eslint` clean.
- **Still open (copy judgment, user's call):** the preview's catch-all
`"Couldn't render this chart: {raw Vega message}"` puts a diagnostic in the headline
(arch 10 says diagnostics go in a disclosure) — correct in the editor, debatable in the
builder; and the builder's terse `"Dataset «X» not found."` drops the next-step the
contract mandates (near-unreachable in the builder). Both noted, not changed.
- **2026-06-11** — **1C + 1D + 1E shipped (the Data section).**
- A new **Data section** at the top of the builder's left pane — "here are your rows; - A new **Data section** at the top of the builder's left pane — "here are your rows;
shape them, then encode them" — emits the spec's top-level `transform` array. shape them, then encode them" — emits the spec's top-level `transform` array.
- **1C · Filters** — a guarded **field + operator + value** predicate shelf. Operators - **1C · Filters** — a guarded **field + operator + value** predicate shelf. Operators
+12
View File
@@ -65,3 +65,15 @@
- [ ] Library search / sort / empty states, the storage monitor, About & Donate - [ ] Library search / sort / empty states, the storage monitor, About & Donate
modals, and the busy indicator all look deliberate and behave per spec. modals, and the busy indicator all look deliberate and behave per spec.
## Driving these checks headlessly
The eye-checks above can be scripted as a headless walk-through (Playwright with the
already-installed Chromium browser cache) for repeatable screenshots, alongside a live
browser.
- **Select by accessible name / role / placeholder, never by CSS class.** CSS-module class
names are content-hashed and unstable; the component tests select the same way.
- **Reaching the Chart Builder:** open Datasets from the **header command button** (the
pane-toggle-strip `Datasets` button only exists once the workspace has content) → **New
Dataset** → paste rows inline → **Build Chart**.
+2 -1
View File
@@ -35,7 +35,7 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
- The **field** dropdown offers the dataset's columns plus any calculated fields. - The **field** dropdown offers the dataset's columns plus any calculated fields.
- The **operators** offered depend on the field's type: a measure or temporal field offers `is` / `is not` / `<` / `≤` / `>` / `≥` / `is between` (two bounds); a category offers `is` / `is not` / `is one of` (a comma-separated membership list). A quantitative value compares as a number; other types compare as text (ISO dates sort correctly as text). - The **operators** offered depend on the field's type: a measure or temporal field offers `is` / `is not` / `<` / `≤` / `>` / `≥` / `is between` (two bounds); a category offers `is` / `is not` / `is one of` (a comma-separated membership list). A quantitative value compares as a number; other types compare as text (ISO dates sort correctly as text).
- A filter can be switched to an **expression** power-mode — a raw Vega predicate expression (e.g. `datum.value > 0`) — for what the guarded shelf can't say. The toggle is reversible. - A filter can be switched to an **expression** power-mode — a raw Vega predicate expression (e.g. `datum.value > 0`) — for what the guarded shelf can't say. The toggle is reversible.
- An **incomplete** filter (no value yet, a blank range bound, an empty expression) is ignored, so the live preview keeps rendering while the user types. - An **incomplete** filter (no value yet, a blank range bound, an empty or syntactically-invalid expression) is ignored, so the live preview keeps rendering while the user types.
### Calculated fields ### Calculated fields
@@ -47,6 +47,7 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
- Both expression inputs (a filter's expression mode, a calculated field) are validated with **Vega's own expression parser** — the same one the chart uses — so a syntax error is reported **inline** the moment it appears, matching exactly what the chart would accept. - Both expression inputs (a filter's expression mode, a calculated field) are validated with **Vega's own expression parser** — the same one the chart uses — so a syntax error is reported **inline** the moment it appears, matching exactly what the chart would accept.
- A `datum.<field>` reference that does not match a known column raises a soft **"unknown field"** warning (a typo guard) without blocking — the value is genuinely valid Vega, it just won't resolve. - A `datum.<field>` reference that does not match a known column raises a soft **"unknown field"** warning (a typo guard) without blocking — the value is genuinely valid Vega, it just won't resolve.
- An expression that does not parse is **left out of the produced spec** (like an incomplete filter or an unnamed calculated field): the inline error is the only feedback, and a half-typed expression never reaches the renderer — the preview keeps showing the last valid chart.
## Inputs and Controls ## Inputs and Controls
@@ -234,11 +234,21 @@
.exprError, .exprError,
.exprWarn { .exprWarn {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0; margin: 0;
font-size: 11px; font-size: 11px;
line-height: 1.4; line-height: 1.4;
} }
/* Severity reads from the glyph shape + colour, not colour alone (arch 10 §3); the
icon inherits the line's colour via currentColor (round error / triangle warning). */
.exprFeedbackIcon {
flex: none;
margin-top: 1px;
}
.exprError { .exprError {
color: var(--support-error); color: var(--support-error);
} }
@@ -256,8 +256,15 @@ describe('ChartBuilderModal', () => {
await Promise.resolve(); await Promise.resolve();
}); });
const alert = container.querySelector('[role="alert"]'); // Polite, not assertive: live per-keystroke validation uses role="status" with a
expect(alert?.textContent).toMatch(/nexpected|Invalid/); // status glyph, never an assertive alert (council: APG Alert / WCAG 2.2.4).
const messages = Array.from(container.querySelectorAll('[role="status"]'));
const errorMsg = messages.find((n) => /nexpected|Invalid/.test(n.textContent ?? ''));
expect(errorMsg).toBeTruthy();
// The expression input is linked to its message and flagged invalid.
const exprInput = container.querySelector('input[aria-label="Filter expression"]');
expect(exprInput?.getAttribute('aria-invalid')).toBe('true');
expect(exprInput?.getAttribute('aria-describedby')).toBe(errorMsg?.id);
}); });
test('the Vega expression reference shows only when an expression is in play (1E)', async () => { test('the Vega expression reference shows only when an expression is in play (1E)', async () => {
+47 -14
View File
@@ -346,11 +346,26 @@ function ChannelBlock({ channel }: { channel: ChannelName }) {
/** /**
* Inline feedback for an expression input (filter expression / calculated field): * Inline feedback for an expression input (filter expression / calculated field):
* a parse error (assertive) takes priority, else a soft warning for `datum.<field>` * a parse error takes priority, else a soft warning for `datum.<field>` references
* references that don't match a known column — a typo guard before the chart renders * that don't match a known column — a typo guard before the chart renders empty (1E).
* empty (1E). Nothing renders for a valid, fully-resolved expression. * Nothing renders for a valid, fully-resolved expression. `messageId` lets the owning
* input point at this node via `aria-describedby`.
*
* Both severities are a **polite** live region carrying a **status glyph** (round
* error / triangle warning), not an assertive alert and never colour alone: the
* expression validates on every keystroke, so an assertive role would interrupt on
* each character (APG Alert / WCAG 2.2.4), and severity must read without colour
* (arch 10 §3; the input also carries `aria-invalid`).
*/ */
function ExprFeedback({ expr, columns }: { expr: string; columns: BuilderColumns }) { function ExprFeedback({
expr,
columns,
messageId,
}: {
expr: string;
columns: BuilderColumns;
messageId?: string;
}) {
const feedback = useMemo(() => { const feedback = useMemo(() => {
const validation = validateExpression(expr); const validation = validateExpression(expr);
if (!validation.valid) { if (!validation.valid) {
@@ -359,19 +374,23 @@ function ExprFeedback({ expr, columns }: { expr: string; columns: BuilderColumns
const unknown = referencedFields(expr).filter((f) => !columns.columns.includes(f)); const unknown = referencedFields(expr).filter((f) => !columns.columns.includes(f));
if (unknown.length > 0) { if (unknown.length > 0) {
const plural = unknown.length > 1 ? 's' : ''; const plural = unknown.length > 1 ? 's' : '';
return { kind: 'warn' as const, text: `Unknown field${plural}: ${unknown.join(', ')}` }; return {
kind: 'warn' as const,
text: `Unknown field${plural}: ${unknown.join(', ')} — not a column in this dataset.`,
};
} }
return null; return null;
}, [expr, columns]); }, [expr, columns]);
if (!feedback) return null; if (!feedback) return null;
return feedback.kind === 'error' ? ( const isError = feedback.kind === 'error';
<p className={styles.exprError} role="alert"> return (
{feedback.text} <p id={messageId} className={isError ? styles.exprError : styles.exprWarn} role="status">
</p> <Icon
) : ( name={isError ? 'status-error' : 'status-warning'}
<p className={styles.exprWarn} role="status"> className={styles.exprFeedbackIcon}
{feedback.text} />
<span>{feedback.text}</span>
</p> </p>
); );
} }
@@ -412,6 +431,9 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
const op = filter.op ?? 'equal'; const op = filter.op ?? 'equal';
const arity = filterOpArity(op); const arity = filterOpArity(op);
const hasColumns = columns.columns.length > 0; const hasColumns = columns.columns.length > 0;
// Links the expression input to its feedback line; harmless when no message renders
// (aria-describedby to an absent id is ignored — GOV.UK error-message association).
const exprMsgId = `filter-${filter.id}-expr-msg`;
return ( return (
<div className={styles.transformBlock}> <div className={styles.transformBlock}>
@@ -423,6 +445,7 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
placeholder={exprPlaceholder(columns, 'filter')} placeholder={exprPlaceholder(columns, 'filter')}
value={filter.expr ?? ''} value={filter.expr ?? ''}
aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined} aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined}
aria-describedby={exprMsgId}
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })} onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
/> />
) : ( ) : (
@@ -496,7 +519,9 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
</div> </div>
)} )}
{expressionMode && <ExprFeedback expr={filter.expr ?? ''} columns={columns} />} {expressionMode && (
<ExprFeedback expr={filter.expr ?? ''} columns={columns} messageId={exprMsgId} />
)}
{hasColumns && ( {hasColumns && (
<button <button
@@ -515,6 +540,7 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) { function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) {
const updateCalculate = useChartBuilderStore((s) => s.updateCalculate); const updateCalculate = useChartBuilderStore((s) => s.updateCalculate);
const removeCalculate = useChartBuilderStore((s) => s.removeCalculate); const removeCalculate = useChartBuilderStore((s) => s.removeCalculate);
const exprMsgId = `calc-${calc.id}-expr-msg`;
return ( return (
<div className={styles.transformBlock}> <div className={styles.transformBlock}>
@@ -535,6 +561,7 @@ function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: Buil
placeholder={exprPlaceholder(columns, 'calc')} placeholder={exprPlaceholder(columns, 'calc')}
value={calc.expr} value={calc.expr}
aria-invalid={!validateExpression(calc.expr).valid || undefined} aria-invalid={!validateExpression(calc.expr).valid || undefined}
aria-describedby={exprMsgId}
onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })} onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
/> />
<button <button
@@ -546,7 +573,7 @@ function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: Buil
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<ExprFeedback expr={calc.expr} columns={columns} /> <ExprFeedback expr={calc.expr} columns={columns} messageId={exprMsgId} />
</div> </div>
); );
} }
@@ -782,9 +809,15 @@ function BuilderPreview() {
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx }); setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
setError(null); setError(null);
} else if (e instanceof DatasetNotFoundError) { } else if (e instanceof DatasetNotFoundError) {
// TODO: this drops the next-step the error contract wants (arch 10); LivePreview
// gives "Create it from Datasets…". Near-unreachable here (the builder opens from
// an existing dataset), so it's terse — restore the next-step if it can be reached.
setError(`Dataset "${e.datasetName}" not found.`); setError(`Dataset "${e.datasetName}" not found.`);
setTooLarge(null); setTooLarge(null);
} else { } else {
// TODO: arch 10 routes a raw diagnostic into a disclosure, not the headline. The
// editor surfaces the Vega message inline by design; the builder could fold it
// behind a details disclosure and keep the headline plain.
setError(`Couldn't render this chart: ${(e as Error).message}`); setError(`Couldn't render this chart: ${(e as Error).message}`);
setTooLarge(null); setTooLarge(null);
} }
+23
View File
@@ -974,6 +974,29 @@ describe('buildTransforms (predicate coercion + shape)', () => {
}; };
expect(buildTransforms(config)).toEqual([]); 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', () => { describe('buildChartSpec — transform integration', () => {
+13 -6
View File
@@ -26,6 +26,7 @@ import type { ColumnStats } from './profile';
import { DISTINCT_CAP } from './profile'; import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference'; import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet'; import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
/** The five mark types the builder offers, in selector order (spec §06). */ /** The five mark types the builder offers, in selector order (spec §06). */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const; 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 { function filterTransformObject(filter: BuilderFilter): Record<string, unknown> | null {
if (filter.mode === 'expression') { if (filter.mode === 'expression') {
const expr = (filter.expr ?? '').trim(); 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); const predicate = predicateObject(filter);
return predicate ? { filter: predicate } : null; 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 { function calculateTransformObject(calc: BuilderCalculate): Record<string, unknown> | null {
const expr = calc.expr.trim(); const expr = calc.expr.trim();
const as = calc.as.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 * The complete top-level `transform` array for a configuration: every calculated
* field first (so filters and encodings can reference the derived columns), then * 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 * every filter, each in the user's list order. Incomplete or unparseable entries (a
* filter, an unnamed calculate) are dropped so a configuration mid-edit still * half-typed predicate, an unnamed calculate, an expression that doesn't parse) are
* produces a renderable spec. An empty result means no `transform` key is emitted. * 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>> { export function buildTransforms(config: BuilderConfig): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = []; const out: Array<Record<string, unknown>> = [];