Skip to content

Commit 4ed4160

Browse files
claude[bot]claude
andauthored
fix(showcase): scope the Delivery Operations status filter to the task widgets (#7612)
The dashboard-scoped global filter carried the showcase_task status vocabulary but was declared as a bare `field: 'status'`, and the five project-bound widgets declared no `filterBindings`. Since a widget without bindings inherits a dashboard filter on its own object's like-named field, the filter also landed on showcase_project.status — whose value domain is disjoint — so those widgets emitted `WHERE status = 'in_review'` against showcase_project, answered 200 OK with a zero, and read 0 for any selection. Name the filter `task_status` for the vocabulary it carries and opt each project-bound widget out with `filterBindings: { task_status: false }`, the same per-widget mechanism Revenue Pulse uses to map region -> sales_region across two objects. dateRange stays inherited: projects do carry created_at. Adds a showcase test pinning the consequence rather than the key: every value a global filter offers must be a value its effective field can hold on each widget it reaches, and a filter must still reach at least one widget. Claude-Session: https://claude.ai/code/session_0127HHmCr5vd3NudQmW6QiNN Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6f3f2f commit 4ed4160

2 files changed

Lines changed: 283 additions & 7 deletions

File tree

examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,36 @@ const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig =
2121
* (active projects, at-risk projects, awaiting-review tasks) — the same
2222
* dataset, sliced different ways;
2323
* • comparison / distribution / trend charts underneath;
24-
* • a global `dateRange` (created_at) and a global status filter so the whole
25-
* board re-scopes from the header.
24+
* • a global `dateRange` (created_at) that every widget inherits, and a global
25+
* `task_status` filter that re-scopes the TASK side of the board only.
2626
*
2727
* Everything binds the semantic datasets by name (ADR-0021), so a metric is
2828
* defined once and reused.
29+
*
30+
* ## Why the project widgets opt out of `task_status` (#7568)
31+
*
32+
* A dashboard filter is broadcast into EVERY widget's analytics query
33+
* (framework#2501); a widget with no `filterBindings` inherits it on its own
34+
* object's like-named field. `task_status` carries the `showcase_task.status`
35+
* vocabulary (backlog / todo / in_progress / in_review / done), and
36+
* `showcase_project` also has a `status` field — with a completely different
37+
* vocabulary (planned / active / on_hold / completed / cancelled). So the
38+
* inherited binding was field-valid and value-empty: every project-bound widget
39+
* emitted `WHERE status = 'in_review'` against `showcase_project` and answered
40+
* `200 OK` with a zero. Four tiles and a chart read 0 for any selection while
41+
* the filter bar looked like it was working.
42+
*
43+
* The two status vocabularies are disjoint, so there is no project field to
44+
* re-target to — the honest binding is an opt-out. Each project-bound widget
45+
* therefore declares `filterBindings: { task_status: false }`, the same
46+
* per-widget mechanism the Revenue Pulse dashboard uses to map `region` →
47+
* `sales_region` across two objects, and the one the Studio widget inspector
48+
* authors (objectui#2586). `dateRange` is left inherited on purpose: projects
49+
* DO carry `created_at`, so that filter is meaningful on both sides.
50+
*
51+
* Read the pair together and the filter's reach is legible from the metadata
52+
* alone: it is named for the vocabulary it carries, and every widget it does
53+
* NOT govern says so on its own line.
2954
*/
3055
export const OpsDashboard: Dashboard = {
3156
name: 'showcase_ops_dashboard',
@@ -35,6 +60,11 @@ export const OpsDashboard: Dashboard = {
3560
dateRange: { field: 'created_at', defaultRange: 'last_90_days', allowCustomRange: true },
3661
globalFilters: [
3762
{
63+
// Named for the vocabulary it carries, not for the column it happens to
64+
// sit on: `status` exists on BOTH showcase objects, so the bare name made
65+
// an opt-out read as "ignore project status" instead of "this control is
66+
// about tasks". `filterBindings` keys reference this `name` (#7568).
67+
name: 'task_status',
3868
field: 'status',
3969
label: 'Task Status',
4070
type: 'select',
@@ -50,18 +80,24 @@ export const OpsDashboard: Dashboard = {
5080
],
5181
widgets: [
5282
// ── KPI hero row — same project dataset, sliced by per-widget filter ──
53-
{ id: 'kpi_active_projects', type: 'metric', title: 'Active Projects', dataset: projectDs, values: ['project_count'], filter: { status: 'active' }, colorVariant: 'blue', layout: { x: 0, y: 0, w: 3, h: 2 } },
54-
{ id: 'kpi_at_risk', type: 'metric', title: 'At-Risk (Red)', dataset: projectDs, values: ['project_count'], filter: { health: 'red' }, colorVariant: 'danger', layout: { x: 3, y: 0, w: 3, h: 2 } },
83+
// Project-bound widgets opt out of `task_status` (see the note above); the
84+
// task-bound tile inherits it and composes it with its own filter.
85+
{ id: 'kpi_active_projects', type: 'metric', title: 'Active Projects', dataset: projectDs, values: ['project_count'], filter: { status: 'active' }, filterBindings: { task_status: false }, colorVariant: 'blue', layout: { x: 0, y: 0, w: 3, h: 2 } },
86+
{ id: 'kpi_at_risk', type: 'metric', title: 'At-Risk (Red)', dataset: projectDs, values: ['project_count'], filter: { health: 'red' }, filterBindings: { task_status: false }, colorVariant: 'danger', layout: { x: 3, y: 0, w: 3, h: 2 } },
5587
{ id: 'kpi_awaiting_review', type: 'metric', title: 'Awaiting Review', dataset: taskDs, values: ['task_count'], filter: { status: 'in_review' }, colorVariant: 'warning', layout: { x: 6, y: 0, w: 3, h: 2 } },
56-
{ id: 'kpi_total_budget', type: 'metric', title: 'Total Budget', dataset: projectDs, values: ['budget_sum'], colorVariant: 'success', layout: { x: 9, y: 0, w: 3, h: 2 } },
88+
{ id: 'kpi_total_budget', type: 'metric', title: 'Total Budget', dataset: projectDs, values: ['budget_sum'], filterBindings: { task_status: false }, colorVariant: 'success', layout: { x: 9, y: 0, w: 3, h: 2 } },
5789

5890
// ── Health + throughput ──────────────────────────────────────────────
59-
{ id: 'col_health', type: 'column', title: 'Projects by Health', dataset: projectDs, dimensions: ['health'], values: ['project_count'], chartConfig: cfg('column', 'health', 'project_count'), layout: { x: 0, y: 2, w: 4, h: 4 } },
91+
{ id: 'col_health', type: 'column', title: 'Projects by Health', dataset: projectDs, dimensions: ['health'], values: ['project_count'], chartConfig: cfg('column', 'health', 'project_count'), filterBindings: { task_status: false }, layout: { x: 0, y: 2, w: 4, h: 4 } },
6092
{ id: 'bar_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('bar', 'status', 'task_count'), layout: { x: 4, y: 2, w: 4, h: 4 } },
6193
{ id: 'donut_priority', type: 'donut', title: 'Priority Mix', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('donut', 'priority', 'task_count'), layout: { x: 8, y: 2, w: 4, h: 4 } },
6294

6395
// ── Trend + account spend ────────────────────────────────────────────
6496
{ id: 'line_created', type: 'line', title: 'Task Throughput (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], chartConfig: cfg('line', 'created_at', 'task_count'), layout: { x: 0, y: 6, w: 6, h: 4 } },
65-
{ id: 'table_spend', type: 'table', title: 'Budget vs Spent by Account', dataset: projectDs, dimensions: ['account'], values: ['project_count', 'budget_sum', 'spent_sum'], layout: { x: 6, y: 6, w: 6, h: 4 } },
97+
// The fifth project-bound widget — same opt-out. #7568's body names four
98+
// (the tiles a reader watches drop to 0); this table zeroed with them,
99+
// silently, because an empty table reads as "no data" rather than as a
100+
// broken filter.
101+
{ id: 'table_spend', type: 'table', title: 'Budget vs Spent by Account', dataset: projectDs, dimensions: ['account'], values: ['project_count', 'budget_sum', 'spent_sum'], filterBindings: { task_status: false }, layout: { x: 6, y: 6, w: 6, h: 4 } },
66102
],
67103
};
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import stack from '../objectstack.config.js';
6+
7+
/**
8+
* Dashboard global filters must be ANSWERABLE on every widget they reach
9+
* (objectstack#7568).
10+
*
11+
* A dashboard-level filter is broadcast into EVERY widget's analytics query
12+
* (framework#2501). A widget that declares no `filterBindings` inherits it on
13+
* its own object's like-named field — the engine doing exactly what the
14+
* metadata says. The authoring trap is that field EXISTENCE and field
15+
* VOCABULARY are different facts, and only the first one was ever checked:
16+
* `packages/lint`'s `dashboard-filter-field-unknown` rule fires when the
17+
* effective field is missing from the bound object, which is the loud failure
18+
* (`no such column`). When the column exists but carries a DIFFERENT set of
19+
* values, nothing fires at all — the query is valid, the backend answers
20+
* `200 OK`, and the widget renders a zero.
21+
*
22+
* That is what #7568 was: Delivery Operations declared a `status` filter with
23+
* the `showcase_task` vocabulary (backlog / todo / in_progress / in_review /
24+
* done), and `showcase_project.status` carries a disjoint one (planned /
25+
* active / on_hold / completed / cancelled). Four KPI tiles and a chart — plus
26+
* a table nobody counted — emitted `WHERE status = 'in_review'` against
27+
* `showcase_project` and read 0 for every selection, while the filter bar
28+
* looked like it was working.
29+
*
30+
* These two tests pin the CONSEQUENCE, not the presence of a key:
31+
*
32+
* 1. every value a filter offers must be a value its effective field can
33+
* actually hold on each widget it reaches — otherwise that selection is
34+
* empty by construction;
35+
* 2. a filter must still reach at least one widget — otherwise the repair for
36+
* (1) is "opt everybody out", which leaves an inert control on the header
37+
* bar (Prime Directive #10, declared ≠ enforced).
38+
*
39+
* The check is generic over every showcase dashboard, so a widget added to the
40+
* project side of Delivery Operations tomorrow is judged the same way rather
41+
* than quietly re-introducing the defect.
42+
*/
43+
44+
type AnyRec = Record<string, unknown>;
45+
46+
/** Coerce a collection (array or name-keyed map) to an array of records. */
47+
function asArray(v: unknown): AnyRec[] {
48+
if (Array.isArray(v)) return v as AnyRec[];
49+
if (v && typeof v === 'object') {
50+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
51+
}
52+
return [];
53+
}
54+
55+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
56+
57+
/** Reserved filter name for the dashboard's built-in date range (#2501). */
58+
const DATE_RANGE_FILTER_NAME = 'dateRange';
59+
60+
interface FilterDef {
61+
name: string;
62+
field: string;
63+
targetWidgets?: string[];
64+
/** Static option VALUES, when the filter declares them. */
65+
optionValues?: string[];
66+
}
67+
68+
/**
69+
* The dashboard's declared filters, keyed by the name widgets bind against.
70+
* Mirrors `dashboardFilterDefs` in `packages/lint/src/validate-widget-bindings.ts`,
71+
* which in turn mirrors objectui's `resolveDashboardFilterDefs`.
72+
*
73+
* The built-in `dateRange` is deliberately NOT included: it carries no option
74+
* vocabulary to compare, and its field-existence half is already the lint
75+
* rule's job.
76+
*/
77+
function filterDefs(dash: AnyRec): FilterDef[] {
78+
const byName = new Map<string, FilterDef>();
79+
for (const f of asArray(dash.globalFilters)) {
80+
const field = str(f.field);
81+
if (!field) continue;
82+
const name = str(f.name) ?? field;
83+
if (name === DATE_RANGE_FILTER_NAME) continue;
84+
const options = asArray(f.options)
85+
.map((o) => o.value)
86+
.filter((v): v is string => typeof v === 'string');
87+
byName.set(name, {
88+
name,
89+
field,
90+
targetWidgets: Array.isArray(f.targetWidgets)
91+
? (f.targetWidgets as unknown[]).filter((w): w is string => typeof w === 'string')
92+
: undefined,
93+
optionValues: options.length > 0 ? options : undefined,
94+
});
95+
}
96+
return [...byName.values()];
97+
}
98+
99+
/**
100+
* Which field of `widget` this filter binds to, or `undefined` when the widget
101+
* is not bound. Precedence mirrors objectui's `resolveBoundField` (and
102+
* `effectiveFilterField` in `packages/lint`): an explicit `filterBindings`
103+
* entry wins (a string re-targets, `false` opts out), then the `targetWidgets`
104+
* allow-list, then the filter's own `field`.
105+
*/
106+
function boundField(widget: AnyRec, def: FilterDef): string | undefined {
107+
const bindings = widget.filterBindings;
108+
const binding = bindings && typeof bindings === 'object'
109+
? (bindings as AnyRec)[def.name]
110+
: undefined;
111+
if (binding === false) return undefined;
112+
const retarget = str(binding);
113+
if (retarget) return retarget;
114+
if (def.targetWidgets && def.targetWidgets.length > 0) {
115+
const id = str(widget.id);
116+
if (!id || !def.targetWidgets.includes(id)) return undefined;
117+
}
118+
return def.field;
119+
}
120+
121+
const dashboards = asArray((stack as AnyRec).dashboards);
122+
123+
const datasetObject = new Map<string, string>();
124+
for (const ds of asArray((stack as AnyRec).datasets)) {
125+
const name = str(ds.name);
126+
const object = str(ds.object);
127+
if (name && object) datasetObject.set(name, object);
128+
}
129+
130+
/** `object name → field name → declared select option values`. */
131+
const selectVocabulary = new Map<string, Map<string, string[]>>();
132+
for (const o of asArray((stack as AnyRec).objects)) {
133+
const name = str(o.name);
134+
if (!name) continue;
135+
const byField = new Map<string, string[]>();
136+
for (const f of asArray(o.fields)) {
137+
const fname = str(f.name);
138+
if (!fname || f.type !== 'select') continue;
139+
const values = asArray(f.options)
140+
.map((opt) => opt.value)
141+
.filter((v): v is string => typeof v === 'string');
142+
if (values.length > 0) byField.set(fname, values);
143+
}
144+
selectVocabulary.set(name, byField);
145+
}
146+
147+
describe('showcase dashboards — global filters are answerable where they land', () => {
148+
it('every value a global filter offers is a value its effective field can hold', () => {
149+
const unsatisfiable: string[] = [];
150+
151+
for (const dash of dashboards) {
152+
const dashName = str(dash.name) ?? '(unnamed dashboard)';
153+
const defs = filterDefs(dash);
154+
if (defs.length === 0) continue;
155+
156+
for (const w of asArray(dash.widgets)) {
157+
const widgetId = str(w.id) ?? '(unnamed widget)';
158+
const object = datasetObject.get(str(w.dataset) ?? '');
159+
const vocabulary = object ? selectVocabulary.get(object) : undefined;
160+
if (!vocabulary) continue; // unbound / unknowable object — not ours to judge
161+
162+
for (const def of defs) {
163+
if (!def.optionValues) continue; // no declared vocabulary to compare
164+
const field = boundField(w, def);
165+
if (!field) continue; // opted out / not targeted — the filter never applies
166+
const allowed = vocabulary.get(field);
167+
if (!allowed) continue; // not a select field: free text/number/date, unjudgeable here
168+
169+
const missing = def.optionValues.filter((v) => !allowed.includes(v));
170+
if (missing.length === 0) continue;
171+
unsatisfiable.push(
172+
`${dashName}${widgetId}: filter "${def.name}" binds to ` +
173+
`${object}.${field}, whose values are [${allowed.join(', ')}] — ` +
174+
`selecting [${missing.join(', ')}] can only ever return zero rows. ` +
175+
`Re-target it (filterBindings: { ${def.name}: '<field>' }) or opt out ` +
176+
`(filterBindings: { ${def.name}: false }).`,
177+
);
178+
}
179+
}
180+
}
181+
182+
expect(unsatisfiable).toEqual([]);
183+
});
184+
185+
it('every global filter still reaches at least one widget', () => {
186+
const inert: string[] = [];
187+
188+
for (const dash of dashboards) {
189+
const dashName = str(dash.name) ?? '(unnamed dashboard)';
190+
const widgets = asArray(dash.widgets);
191+
for (const def of filterDefs(dash)) {
192+
const reached = widgets.filter((w) => boundField(w, def) !== undefined);
193+
if (reached.length === 0) {
194+
inert.push(
195+
`${dashName}: filter "${def.name}" is bound by no widget — it renders on ` +
196+
`the header bar and changes nothing. Bind it to a widget or remove it.`,
197+
);
198+
}
199+
}
200+
}
201+
202+
expect(inert).toEqual([]);
203+
});
204+
205+
/**
206+
* The #7568 case itself, stated in the terms a reader of the dashboard cares
207+
* about: the Task Status control governs the task side and nothing else. The
208+
* generic tests above would also catch a regression here, but only as a
209+
* vocabulary mismatch — this one names the intent, so a future author who
210+
* re-targets a project widget onto some other project column sees which
211+
* decision they are overturning.
212+
*/
213+
it('Delivery Operations: task_status governs the task widgets and no project widget', () => {
214+
const ops = dashboards.find((d) => str(d.name) === 'showcase_ops_dashboard');
215+
expect(ops, 'showcase_ops_dashboard is registered').toBeDefined();
216+
217+
const def = filterDefs(ops as AnyRec).find((d) => d.name === 'task_status');
218+
expect(def, 'the Task Status filter is named task_status').toBeDefined();
219+
220+
const reach: Record<string, string> = {};
221+
for (const w of asArray((ops as AnyRec).widgets)) {
222+
const id = str(w.id) ?? '(unnamed widget)';
223+
const object = datasetObject.get(str(w.dataset) ?? '') ?? '(unknown object)';
224+
const field = boundField(w, def as FilterDef);
225+
reach[id] = field ? `${object}.${field}` : 'opted out';
226+
}
227+
228+
expect(reach).toEqual({
229+
kpi_active_projects: 'opted out',
230+
kpi_at_risk: 'opted out',
231+
kpi_awaiting_review: 'showcase_task.status',
232+
kpi_total_budget: 'opted out',
233+
col_health: 'opted out',
234+
bar_status: 'showcase_task.status',
235+
donut_priority: 'showcase_task.status',
236+
line_created: 'showcase_task.status',
237+
table_spend: 'opted out',
238+
});
239+
});
240+
});

0 commit comments

Comments
 (0)