Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,11 +21,36 @@ const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig =
* (active projects, at-risk projects, awaiting-review tasks) — the same
* dataset, sliced different ways;
* • comparison / distribution / trend charts underneath;
* • a global `dateRange` (created_at) and a global status filter so the whole
* board re-scopes from the header.
* • a global `dateRange` (created_at) that every widget inherits, and a global
* `task_status` filter that re-scopes the TASK side of the board only.
*
* Everything binds the semantic datasets by name (ADR-0021), so a metric is
* defined once and reused.
*
* ## Why the project widgets opt out of `task_status` (#7568)
*
* A dashboard filter is broadcast into EVERY widget's analytics query
* (framework#2501); a widget with no `filterBindings` inherits it on its own
* object's like-named field. `task_status` carries the `showcase_task.status`
* vocabulary (backlog / todo / in_progress / in_review / done), and
* `showcase_project` also has a `status` field — with a completely different
* vocabulary (planned / active / on_hold / completed / cancelled). So the
* inherited binding was field-valid and value-empty: every project-bound widget
* emitted `WHERE status = 'in_review'` against `showcase_project` and answered
* `200 OK` with a zero. Four tiles and a chart read 0 for any selection while
* the filter bar looked like it was working.
*
* The two status vocabularies are disjoint, so there is no project field to
* re-target to — the honest binding is an opt-out. Each project-bound widget
* therefore declares `filterBindings: { task_status: false }`, the same
* per-widget mechanism the Revenue Pulse dashboard uses to map `region` →
* `sales_region` across two objects, and the one the Studio widget inspector
* authors (objectui#2586). `dateRange` is left inherited on purpose: projects
* DO carry `created_at`, so that filter is meaningful on both sides.
*
* Read the pair together and the filter's reach is legible from the metadata
* alone: it is named for the vocabulary it carries, and every widget it does
* NOT govern says so on its own line.
*/
export const OpsDashboard: Dashboard = {
name: 'showcase_ops_dashboard',
Expand All@@ -35,6 +60,11 @@ export const OpsDashboard: Dashboard = {
dateRange: { field: 'created_at', defaultRange: 'last_90_days', allowCustomRange: true },
globalFilters: [
{
// Named for the vocabulary it carries, not for the column it happens to
// sit on: `status` exists on BOTH showcase objects, so the bare name made
// an opt-out read as "ignore project status" instead of "this control is
// about tasks". `filterBindings` keys reference this `name` (#7568).
name: 'task_status',
field: 'status',
label: 'Task Status',
type: 'select',
Expand All@@ -50,18 +80,24 @@ export const OpsDashboard: Dashboard = {
],
widgets: [
// ── KPI hero row — same project dataset, sliced by per-widget filter ──
{ 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 } },
{ 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 } },
// Project-bound widgets opt out of `task_status` (see the note above); the
// task-bound tile inherits it and composes it with its own filter.
{ 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 } },
{ 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 } },
{ 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 } },
{ 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 } },
{ 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 } },

// ── Health + throughput ──────────────────────────────────────────────
{ 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 } },
{ 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 } },
{ 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 } },
{ 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 } },

// ── Trend + account spend ────────────────────────────────────────────
{ 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 } },
{ 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 } },
// The fifth project-bound widget — same opt-out. #7568's body names four
// (the tiles a reader watches drop to 0); this table zeroed with them,
// silently, because an empty table reads as "no data" rather than as a
// broken filter.
{ 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 } },
],
};
240 changes: 240 additions & 0 deletions examples/app-showcase/test/dashboard-filter-vocabulary.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';

import stack from '../objectstack.config.js';

/**
* Dashboard global filters must be ANSWERABLE on every widget they reach
* (objectstack#7568).
*
* A dashboard-level filter is broadcast into EVERY widget's analytics query
* (framework#2501). A widget that declares no `filterBindings` inherits it on
* its own object's like-named field — the engine doing exactly what the
* metadata says. The authoring trap is that field EXISTENCE and field
* VOCABULARY are different facts, and only the first one was ever checked:
* `packages/lint`'s `dashboard-filter-field-unknown` rule fires when the
* effective field is missing from the bound object, which is the loud failure
* (`no such column`). When the column exists but carries a DIFFERENT set of
* values, nothing fires at all — the query is valid, the backend answers
* `200 OK`, and the widget renders a zero.
*
* That is what #7568 was: Delivery Operations declared a `status` filter with
* the `showcase_task` vocabulary (backlog / todo / in_progress / in_review /
* done), and `showcase_project.status` carries a disjoint one (planned /
* active / on_hold / completed / cancelled). Four KPI tiles and a chart — plus
* a table nobody counted — emitted `WHERE status = 'in_review'` against
* `showcase_project` and read 0 for every selection, while the filter bar
* looked like it was working.
*
* These two tests pin the CONSEQUENCE, not the presence of a key:
*
* 1. every value a filter offers must be a value its effective field can
* actually hold on each widget it reaches — otherwise that selection is
* empty by construction;
* 2. a filter must still reach at least one widget — otherwise the repair for
* (1) is "opt everybody out", which leaves an inert control on the header
* bar (Prime Directive #10, declared ≠ enforced).
*
* The check is generic over every showcase dashboard, so a widget added to the
* project side of Delivery Operations tomorrow is judged the same way rather
* than quietly re-introducing the defect.
*/

type AnyRec = Record<string, unknown>;

/** Coerce a collection (array or name-keyed map) to an array of records. */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}

const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);

/** Reserved filter name for the dashboard's built-in date range (#2501). */
const DATE_RANGE_FILTER_NAME = 'dateRange';

interface FilterDef {
name: string;
field: string;
targetWidgets?: string[];
/** Static option VALUES, when the filter declares them. */
optionValues?: string[];
}

/**
* The dashboard's declared filters, keyed by the name widgets bind against.
* Mirrors `dashboardFilterDefs` in `packages/lint/src/validate-widget-bindings.ts`,
* which in turn mirrors objectui's `resolveDashboardFilterDefs`.
*
* The built-in `dateRange` is deliberately NOT included: it carries no option
* vocabulary to compare, and its field-existence half is already the lint
* rule's job.
*/
function filterDefs(dash: AnyRec): FilterDef[] {
const byName = new Map<string, FilterDef>();
for (const f of asArray(dash.globalFilters)) {
const field = str(f.field);
if (!field) continue;
const name = str(f.name) ?? field;
if (name === DATE_RANGE_FILTER_NAME) continue;
const options = asArray(f.options)
.map((o) => o.value)
.filter((v): v is string => typeof v === 'string');
byName.set(name, {
name,
field,
targetWidgets: Array.isArray(f.targetWidgets)
? (f.targetWidgets as unknown[]).filter((w): w is string => typeof w === 'string')
: undefined,
optionValues: options.length > 0 ? options : undefined,
});
}
return [...byName.values()];
}

/**
* Which field of `widget` this filter binds to, or `undefined` when the widget
* is not bound. Precedence mirrors objectui's `resolveBoundField` (and
* `effectiveFilterField` in `packages/lint`): an explicit `filterBindings`
* entry wins (a string re-targets, `false` opts out), then the `targetWidgets`
* allow-list, then the filter's own `field`.
*/
function boundField(widget: AnyRec, def: FilterDef): string | undefined {
const bindings = widget.filterBindings;
const binding = bindings && typeof bindings === 'object'
? (bindings as AnyRec)[def.name]
: undefined;
if (binding === false) return undefined;
const retarget = str(binding);
if (retarget) return retarget;
if (def.targetWidgets && def.targetWidgets.length > 0) {
const id = str(widget.id);
if (!id || !def.targetWidgets.includes(id)) return undefined;
}
return def.field;
}

const dashboards = asArray((stack as AnyRec).dashboards);

const datasetObject = new Map<string, string>();
for (const ds of asArray((stack as AnyRec).datasets)) {
const name = str(ds.name);
const object = str(ds.object);
if (name && object) datasetObject.set(name, object);
}

/** `object name → field name → declared select option values`. */
const selectVocabulary = new Map<string, Map<string, string[]>>();
for (const o of asArray((stack as AnyRec).objects)) {
const name = str(o.name);
if (!name) continue;
const byField = new Map<string, string[]>();
for (const f of asArray(o.fields)) {
const fname = str(f.name);
if (!fname || f.type !== 'select') continue;
const values = asArray(f.options)
.map((opt) => opt.value)
.filter((v): v is string => typeof v === 'string');
if (values.length > 0) byField.set(fname, values);
}
selectVocabulary.set(name, byField);
}

describe('showcase dashboards — global filters are answerable where they land', () => {
it('every value a global filter offers is a value its effective field can hold', () => {
const unsatisfiable: string[] = [];

for (const dash of dashboards) {
const dashName = str(dash.name) ?? '(unnamed dashboard)';
const defs = filterDefs(dash);
if (defs.length === 0) continue;

for (const w of asArray(dash.widgets)) {
const widgetId = str(w.id) ?? '(unnamed widget)';
const object = datasetObject.get(str(w.dataset) ?? '');
const vocabulary = object ? selectVocabulary.get(object) : undefined;
if (!vocabulary) continue; // unbound / unknowable object — not ours to judge

for (const def of defs) {
if (!def.optionValues) continue; // no declared vocabulary to compare
const field = boundField(w, def);
if (!field) continue; // opted out / not targeted — the filter never applies
const allowed = vocabulary.get(field);
if (!allowed) continue; // not a select field: free text/number/date, unjudgeable here

const missing = def.optionValues.filter((v) => !allowed.includes(v));
if (missing.length === 0) continue;
unsatisfiable.push(
`${dashName} › ${widgetId}: filter "${def.name}" binds to ` +
`${object}.${field}, whose values are [${allowed.join(', ')}] — ` +
`selecting [${missing.join(', ')}] can only ever return zero rows. ` +
`Re-target it (filterBindings: { ${def.name}: '<field>' }) or opt out ` +
`(filterBindings: { ${def.name}: false }).`,
);
}
}
}

expect(unsatisfiable).toEqual([]);
});

it('every global filter still reaches at least one widget', () => {
const inert: string[] = [];

for (const dash of dashboards) {
const dashName = str(dash.name) ?? '(unnamed dashboard)';
const widgets = asArray(dash.widgets);
for (const def of filterDefs(dash)) {
const reached = widgets.filter((w) => boundField(w, def) !== undefined);
if (reached.length === 0) {
inert.push(
`${dashName}: filter "${def.name}" is bound by no widget — it renders on ` +
`the header bar and changes nothing. Bind it to a widget or remove it.`,
);
}
}
}

expect(inert).toEqual([]);
});

/**
* The #7568 case itself, stated in the terms a reader of the dashboard cares
* about: the Task Status control governs the task side and nothing else. The
* generic tests above would also catch a regression here, but only as a
* vocabulary mismatch — this one names the intent, so a future author who
* re-targets a project widget onto some other project column sees which
* decision they are overturning.
*/
it('Delivery Operations: task_status governs the task widgets and no project widget', () => {
const ops = dashboards.find((d) => str(d.name) === 'showcase_ops_dashboard');
expect(ops, 'showcase_ops_dashboard is registered').toBeDefined();

const def = filterDefs(ops as AnyRec).find((d) => d.name === 'task_status');
expect(def, 'the Task Status filter is named task_status').toBeDefined();

const reach: Record<string, string> = {};
for (const w of asArray((ops as AnyRec).widgets)) {
const id = str(w.id) ?? '(unnamed widget)';
const object = datasetObject.get(str(w.dataset) ?? '') ?? '(unknown object)';
const field = boundField(w, def as FilterDef);
reach[id] = field ? `${object}.${field}` : 'opted out';
}

expect(reach).toEqual({
kpi_active_projects: 'opted out',
kpi_at_risk: 'opted out',
kpi_awaiting_review: 'showcase_task.status',
kpi_total_budget: 'opted out',
col_health: 'opted out',
bar_status: 'showcase_task.status',
donut_priority: 'showcase_task.status',
line_created: 'showcase_task.status',
table_spend: 'opted out',
});
});
});
Loading