mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: drop unparseable expressions mid-edit, polite glyphed inline feedback
This commit is contained in:
@@ -234,11 +234,21 @@
|
||||
|
||||
.exprError,
|
||||
.exprWarn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
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 {
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
@@ -256,8 +256,15 @@ describe('ChartBuilderModal', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toMatch(/nexpected|Invalid/);
|
||||
// Polite, not assertive: live per-keystroke validation uses role="status" with a
|
||||
// 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 () => {
|
||||
|
||||
@@ -346,11 +346,26 @@ 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.
|
||||
* a parse error 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. `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 validation = validateExpression(expr);
|
||||
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));
|
||||
if (unknown.length > 0) {
|
||||
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;
|
||||
}, [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}
|
||||
const isError = feedback.kind === 'error';
|
||||
return (
|
||||
<p id={messageId} className={isError ? styles.exprError : styles.exprWarn} role="status">
|
||||
<Icon
|
||||
name={isError ? 'status-error' : 'status-warning'}
|
||||
className={styles.exprFeedbackIcon}
|
||||
/>
|
||||
<span>{feedback.text}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -412,6 +431,9 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
|
||||
const op = filter.op ?? 'equal';
|
||||
const arity = filterOpArity(op);
|
||||
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 (
|
||||
<div className={styles.transformBlock}>
|
||||
@@ -423,6 +445,7 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
|
||||
placeholder={exprPlaceholder(columns, 'filter')}
|
||||
value={filter.expr ?? ''}
|
||||
aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined}
|
||||
aria-describedby={exprMsgId}
|
||||
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
@@ -496,7 +519,9 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expressionMode && <ExprFeedback expr={filter.expr ?? ''} columns={columns} />}
|
||||
{expressionMode && (
|
||||
<ExprFeedback expr={filter.expr ?? ''} columns={columns} messageId={exprMsgId} />
|
||||
)}
|
||||
|
||||
{hasColumns && (
|
||||
<button
|
||||
@@ -515,6 +540,7 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
|
||||
function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) {
|
||||
const updateCalculate = useChartBuilderStore((s) => s.updateCalculate);
|
||||
const removeCalculate = useChartBuilderStore((s) => s.removeCalculate);
|
||||
const exprMsgId = `calc-${calc.id}-expr-msg`;
|
||||
|
||||
return (
|
||||
<div className={styles.transformBlock}>
|
||||
@@ -535,6 +561,7 @@ function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: Buil
|
||||
placeholder={exprPlaceholder(columns, 'calc')}
|
||||
value={calc.expr}
|
||||
aria-invalid={!validateExpression(calc.expr).valid || undefined}
|
||||
aria-describedby={exprMsgId}
|
||||
onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
@@ -546,7 +573,7 @@ function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: Buil
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<ExprFeedback expr={calc.expr} columns={columns} />
|
||||
<ExprFeedback expr={calc.expr} columns={columns} messageId={exprMsgId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -782,9 +809,15 @@ function BuilderPreview() {
|
||||
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
|
||||
setError(null);
|
||||
} 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.`);
|
||||
setTooLarge(null);
|
||||
} 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}`);
|
||||
setTooLarge(null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user