Skip to content

Commit 0368504

Browse files
feat(lint)!: refuse a widget filterBindings key that names no declared dashboard filter (#27527)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBUKsd8za5Xus285ev54wetJ)_ Fixes #27445 Triage-ref: 6491434802 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` already teaches that a `filterBindings` key is the declared filter's `name` (defaulting to `field`); nothing there teaches an undeclared key, so no `skills/**` edit. Docs updated here: `content/docs/ui/dashboards.mdx`, `content/docs/deployment/validating-metadata.mdx`. ## 维护者速读 - 改了什么:新增 lint 规则 `dashboard-filter-binding-unknown`(error)。部件 `filterBindings` 的键若不是该仪表盘已声明的过滤器名,`os validate` / `os build` / `os lint` 和元数据发布门(`422 INVALID_METADATA`)都会拒绝;草稿照常保存,`_diagnostics` 给出同样的结论。消息写明部件 id、错误的键、已声明的过滤器,并给出 Did you mean。 - 为什么改:运行时按已声明过滤器的名字去读绑定,未声明的键永远不会被读到。拼错的退出(`{ regoin: false }`)会让部件仍被那个过滤器筛选,数字悄悄变了。 - 风险与回滚:BREAKING(收窄),ADR-0087 登记 D3 条目 `ui-dashboard-filter-binding-unknown-refused`,无 D2(盘点没有任何生产者写出未声明的键)。已存的 `sys_metadata` 仪表盘照常加载,下次保存/发布才会被拒。回滚即 revert。 - 先看:`packages/lint/src/validate-widget-bindings.ts` 的 (a-1) 段。 - 两分钟验证:`cd packages/lint && pnpm exec vitest run src/validate-widget-bindings.test.ts -t "dashboard-filter-binding-unknown"`,预期全部通过。 - 需要你做的:无,席位落地(需合同复核)。 ## What changes **One new error id in `validateWidgetBindings`.** For every widget, each key of `filterBindings` must name a filter the dashboard declares. The declared names are the ones the rule's existing `dashboardFilterDefs` builds, the same set objectui's `resolveDashboardFilterDefs` builds at render time: each `globalFilters[]` entry under its `name` (its `field` when unnamed), and `dateRange` only when the dashboard declares `dateRange`. A key outside that set is reported at `dashboards[i].widgets[j].filterBindings.<key>`: > widget "kpi_deals" binds `filterBindings.regoin`, but dashboard "deal_board" declares no filter named `regoin` (declared: dateRange, region), so the binding is never read with the fix `Rename the key to a declared filter name, or delete the entry. Did you mean "region"?` (a `dateRange` key on a dashboard with no `dateRange` gets "Declare `dateRange` on the dashboard, or delete the `dateRange` entry."). The check runs before the dataset limb because it needs no dataset, and it is not suppressible. **Why error, not the `chart-field-unknown` warning tier.** #15463 demoted `chart-field-unknown` because its three keys only re-dress the same numbers. A `filterBindings` key decides which rows the widget counts: a misspelled opt-out leaves the widget scoped by the filter the author opted it out of, and a misspelled re-target leaves the filter on its default field. Triage 6491434802 graded it a refusal at draft and publish. **Route.** The rule rides `validateWidgetBindings`, which is already on the `dashboard` runtime-gate path with per-rule `surfaces` (the 2026-08-15 ruling's class), so the publish door refuses it with no new wiring and a stored row is never judged on load. No spec superRefine: `DashboardWidgetSchema` cannot see its dashboard's filters, and a parse refusal would move the load path. - `packages/lint`: the rule and `DASHBOARD_FILTER_BINDING_UNKNOWN` (barrel-exported), its `os explain` entry in `rule-explanations.ts`, the module docblock bullet. - `packages/spec`: `DashboardWidgetSchema.filterBindings` TSDoc and `.describe()` name the rule and the valid keys (`content/docs/references/ui/dashboard.mdx` regenerated); ADR-0087 D3 entry `18.ui-dashboard-filter-binding-unknown-refused` (no conversion: only the author knows which filter a misspelled key meant). - Docs: a row in the "Where a Filter's `field` Resolves" table and a paragraph under Per-Widget Filter Bindings (`dashboards.mdx`); a paragraph in `validating-metadata.mdx`. - Changeset `27445-dashboard-filter-binding-unknown.md`: `@objectstack/lint` minor, `@objectstack/spec` minor, marker `registered ui-dashboard-filter-binding-unknown-refused`. ## Census (Phase 1) Measured on main `273137d2` and the public HotCRM clone at `47a0666`: - Producers: 0 offenders. Showcase `showcase_revenue_pulse` (keys `dateRange`, `region`; declares both) and `showcase_ops_dashboard` (`task_status`; declared); platform `system_overview` (`created_at`; an unnamed `globalFilters` entry whose name defaults to its field); HotCRM `executive_dashboard`, `sales_dashboard`, `crm_overview_dashboard`, `service_dashboard` (`dateRange`, `owner_id`, `lead_source`, `type`, `priority`; all declared, the unnamed entries by field). `docs/**` and `skills/**` snippets are widget-array fragments. So no D2. - `dateRange` is valid only when declared: lint's `dashboardFilterDefs`, objectui's `resolveDashboardFilterDefs` and `@objectstack/verify`'s dashboard-window proof all build the `dateRange` def only when `dash.dateRange` is set, so an undeclared one was inert everywhere. - Studio's widget inspector writes `filterBindings` rows only for declared filter defs. Renaming or removing a global filter does not prune existing widget bindings; that dashboard's next save is now refused naming the stale key, which the widget's generic `Filter Bindings` form row can delete. ## Tests - `packages/lint/src/validate-widget-bindings.test.ts` (new describe, 12 cases): the card's body (exact message, path and fix), re-target spelling with one finding per key, declared keys including `dateRange` pass, an unnamed `globalFilters` entry bound by `field`, a custom `name` replaces the field as the key, `dateRange` only when declared, a dashboard with no filters, judged with no dataset and not suppressible, silent without `filterBindings`, gates `validate` and `build`, the `os explain` entry. - `packages/lint/src/runtime-gate.test.ts`: the door accept-set pin goes from five ids to six, and a new case pins both halves at the door (a declared binding publishes; `regoin` is refused at its path with the declared list and the Did you mean). - `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`: a seventh case on the real save/read/publish seams (the draft saves, reads `valid: false` with the publish's words, the publish answers `422 INVALID_METADATA`); and a stored-row pair: a row written with the authoring gate off loads with `{ regoin: false }` intact, and its next save is refused 422 naming the key and `(declared: region)`. The stub engine's matcher learned `$in`, which the active read's type clause uses. - Ablation: with the rule's check replaced by `continue`, 10 lint cases go red; restored, green. ## Verification Head `4759433d`, off main `273137d2`. - Under `os-verify-lock`: build of the `@objectstack/lint...` and `@objectstack/metadata-protocol...` closures exit 0; `@objectstack/spec` `check:generated` exit 0 (after `gen:docs` for the describe); `@objectstack/spec` `test` (750 files, 22313 tests) and `test:repo` (86 files, 1419 tests) exit 0; `@objectstack/lint` test (177 files, 7602 tests) and typecheck exit 0; `@objectstack/metadata-protocol` test (279 files passed, 4 skipped; 21777 tests) and typecheck exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 107 derived families, each run with its exit recorded. 103 exit 0 on the first pass; four exit 3 for prerequisites (`check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched; `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` need a full build). After `git fetch origin 621a487` and a full `turbo run build` under the lock (11m38s on a shared box), all four exit 0. `dispatch-gates --ran`: 107 derived, 107 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. - ESLint on the changed source and test files: 0 errors. ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. ESLint on the changed files: 0 errors; `pnpm lint` is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is in Chinese by the repo's dispatch rule.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzeTeGTp7gp3ECrLPMf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57c51aa commit 0368504

12 files changed

Lines changed: 364 additions & 6 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
feat(lint)!: `dashboard-filter-binding-unknown` refuses a widget `filterBindings` key that names no filter the dashboard declares, naming the widget, the key and the declared filters
7+
8+
Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse a dashboard whose widget `filterBindings` carries a key that names no declared filter (`{ regoin: false }` beside a `region` filter, or a `dateRange` key on a dashboard that declares no `dateRange`), which they accepted before. A draft still saves, and its `_diagnostics` read reports the same verdict. No accept set is widened; one rule id and its exported constant `DASHBOARD_FILTER_BINDING_UNKNOWN` are added.
9+
Metadata-protocol: narrowed
10+
11+
<!-- adr-0087: registered ui-dashboard-filter-binding-unknown-refused -->
12+
13+
**BREAKING** accept-set narrowing at every authoring door for `dashboard`.
14+
15+
- **Before.** `PUT /api/v1/meta/dashboard/<name>?mode=draft` and the publish that followed both answered `200` for a widget binding `filterBindings: { no_such_filter: 'status' }`. The runtime reads a binding by looking up each declared filter's name, so the key was never read: a misspelled opt-out left the widget scoped by the filter it was meant to ignore, and a misspelled re-target left the filter on its default field.
16+
- **Now.** The rule reports the key at `widgets[i].filterBindings.<key>` at `error`, naming the widget, the key and the declared filters, with the nearest declared name as a "Did you mean". A key is valid when it names a `globalFilters` entry (by its `name`, or its `field` when it has no `name`) or is `dateRange` on a dashboard that declares `dateRange`. It is not suppressible.
17+
- **Who sees it.** A dashboard with a misspelled or stale binding key, for example one left behind after its filter was renamed or removed. No dashboard in this repository's examples or platform objects, and none in the public HotCRM app, binds an undeclared key.
18+
- **Stored rows.** A `sys_metadata` dashboard written before this release with such a key still loads and renders as before: the rule judges the authoring path, not the load path. Its next save or publish is refused until the key is fixed.
19+
20+
**The remedy.** Rename the key to one of the declared filter names the finding lists, declare the filter on the dashboard, or delete the entry.

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ dashboard, widget, filter, field, and object. Opt a widget out with
5757
`filterBindings: { <name>: false }`, or re-target the filter to one of that
5858
object's own fields.
5959

60+
The keys of `filterBindings` are checked too. Each must name a filter the
61+
dashboard declares (a `globalFilters[]` entry's `name`, or its `field` when it
62+
has none, and `dateRange` only when the dashboard declares one). A key naming
63+
anything else is never read at runtime, so a misspelled opt-out leaves the
64+
widget filtered after all; `os validate` refuses it as
65+
`dashboard-filter-binding-unknown`, listing the declared filters.
66+
6067
This is no longer only a CLI verdict. Since #7529 the same rule also runs at the
6168
**runtime publish door** for `dashboard` writes, so a board published from
6269
Studio, `PUT /api/v1/meta/*` or an MCP/AI author is refused there too — the same

‎content/docs/references/ui/dashboard.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const result = DashboardSchema.parse(data);
7676
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
7777
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
7878
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
79-
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
79+
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out. Each key must name a filter the dashboard declares (dateRange only when declared); lint rule dashboard-filter-binding-unknown refuses any other |
8080
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
8181

8282
### Nested Shape: `Dashboard.dateRange`
@@ -176,7 +176,7 @@ Dashboard header action
176176
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
177177
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
178178
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
179-
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
179+
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out. Each key must name a filter the dashboard declares (dateRange only when declared); lint rule dashboard-filter-binding-unknown refuses any other |
180180
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
181181

182182
### Allowed Values: `DashboardWidget.type`

‎content/docs/ui/dashboards.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ They are two different namespaces:
466466
| :--- | :--- | :--- |
467467
| `globalFilters[].field` (after any `filterBindings` re-target) | The bound widget's `dataset.object` — a bare name against that object's own fields, a dotted `relationship.field` path hop by hop with its prefix declared in the dataset's `include` | `dashboard-filter-field-unknown`, severity **error** |
468468
| `widgets[].dimensions[]` | The dataset's declared `dimensions`, by name | `widget-dimension-unknown`, severity **error** |
469+
| `widgets[].filterBindings` keys | The dashboard's declared filters, by name: each `globalFilters[].name` (its `field` when unnamed), and `dateRange` only when the dashboard declares `dateRange` | `dashboard-filter-binding-unknown`, severity **error** |
469470

470471
Two consequences follow, and their asymmetry is the part that surprises authors:
471472

@@ -547,6 +548,12 @@ Binding precedence: an explicit `filterBindings` entry (string override or
547548
`false` opt-out) → the filter's legacy `targetWidgets` allow-list → the
548549
filter's own `field`.
549550

551+
Each key must name a filter the dashboard declares. The dashboard looks a
552+
binding up by the declared filter's name, so a misspelled key (`regoin` for
553+
`region`) is never read and the widget keeps the filter it was meant to drop
554+
or re-target. `os validate` and the publish door refuse such a key with
555+
`dashboard-filter-binding-unknown`, naming the declared filters.
556+
550557
### In the Console
551558

552559
- **The filter bar.** The date range and each global filter render as controls above the

‎packages/lint/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export {
4040
// field, now that it is resolved on the object graph rather than skipped
4141
// whenever it was dotted.
4242
DASHBOARD_FILTER_FIELD_NOT_INCLUDED,
43+
// [#27445] A `filterBindings` KEY naming no filter the dashboard declares:
44+
// the runtime reads a binding by the declared filter's name, so the entry is
45+
// never read and the widget keeps the filter it meant to drop or re-target.
46+
DASHBOARD_FILTER_BINDING_UNKNOWN,
4347
// [#14148] The widget's OWN two references, at the same site: the keys of its
4448
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
4549
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,25 @@ const DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {
379379
],
380380
};
381381

382+
const DASHBOARD_FILTER_BINDING_UNKNOWN_EXPLANATION: RuleExplanation = {
383+
rule: 'dashboard-filter-binding-unknown',
384+
covers: 'which keys a widget\'s filterBindings may name',
385+
paragraphs: [
386+
'A widget\'s `filterBindings` maps a dashboard filter\'s NAME to one of the widget\'s own fields ' +
387+
'(re-target) or to `false` (opt out). The names a widget may bind are the ones its dashboard ' +
388+
'declares: each `globalFilters` entry under its `name` (its `field` when `name` is omitted), and ' +
389+
'the reserved name `dateRange` when the dashboard declares `dateRange`.',
390+
'The runtime reads a binding by looking up each declared filter\'s name, so a key that names no ' +
391+
'declared filter is never read. A misspelled opt-out leaves the widget scoped by the filter it ' +
392+
'was meant to ignore, and a misspelled re-target leaves the filter on its default field. Either ' +
393+
'way the widget counts different rows than the author wrote, with nothing on the page to say so, ' +
394+
'which is why the rule is an error and is not suppressible.',
395+
'To fix it, rename the key to a declared filter name (the finding names the nearest one), declare ' +
396+
'the filter on the dashboard, or delete the entry. A `dateRange` key on a dashboard that declares ' +
397+
'no `dateRange` is reported the same way: there is no date range for it to bind.',
398+
],
399+
};
400+
382401
const DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION: RuleExplanation = {
383402
rule: 'dashboard-filter-field-not-included',
384403
covers: 'why a filter\'s relationship prefix must be included',
@@ -5179,6 +5198,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
51795198
[WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION.rule]: WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION,
51805199
[DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION,
51815200
[DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION,
5201+
[DASHBOARD_FILTER_BINDING_UNKNOWN_EXPLANATION.rule]: DASHBOARD_FILTER_BINDING_UNKNOWN_EXPLANATION,
51825202
[DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION,
51835203
[WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION,
51845204
[WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION.rule]: WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION,

‎packages/lint/src/runtime-gate.test.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
856856
expect(dangling.errors).toHaveLength(1);
857857
});
858858

859-
it('ALL FIVE error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => {
859+
it('ALL SIX error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => {
860860
// `surfaces` is per-rule, so the flip enforces the rule's whole error set,
861861
// not just `widget-dataset-unknown`. The ruling accepted the class as one
862862
// coherent "this board cannot render" set — this test makes that
@@ -897,6 +897,14 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
897897
dashboard([cleanWidget()], { dateRange: { field: 'close_date' } }),
898898
['dashboard-filter-field-unknown'],
899899
],
900+
[
901+
// [#27445] The sixth id joins through the same per-rule `surfaces`: a
902+
// binding key the runtime never reads leaves the widget counting rows
903+
// the author scoped out, so it is refused at the door with the five.
904+
'filterBindings key naming no declared dashboard filter',
905+
dashboard([cleanWidget({ filterBindings: { no_such_filter: false } })]),
906+
['dashboard-filter-binding-unknown'],
907+
],
900908
];
901909

902910
const seen = new Set<string>();
@@ -909,9 +917,10 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
909917
for (const e of errors) seen.add(e.rule);
910918
}
911919

912-
// Non-vacuous, and the ruling's whole accept-set as #15463 left it:
913-
// exactly the five. `chart-field-unknown` is deliberately absent.
920+
// Non-vacuous, and the ruling's whole accept-set as #15463 left it plus
921+
// #27445's id: exactly the six. `chart-field-unknown` is deliberately absent.
914922
expect([...seen].sort()).toEqual([
923+
'dashboard-filter-binding-unknown',
915924
'dashboard-filter-field-unknown',
916925
'widget-dataset-unknown',
917926
'widget-dimension-unknown',
@@ -921,6 +930,18 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
921930
expect(seen.has('chart-field-unknown')).toBe(false);
922931
});
923932

933+
it('[#27445] a binding to a DECLARED filter passes the door; a misspelled key is refused at its path', () => {
934+
const declared = { globalFilters: [{ name: 'region', field: 'region', type: 'select' }] };
935+
const ok = gateDashboard(dashboard([cleanWidget({ filterBindings: { region: false } })], declared));
936+
expect(ok.errors, JSON.stringify(ok.errors)).toEqual([]);
937+
938+
const typo = gateDashboard(dashboard([cleanWidget({ filterBindings: { regoin: false } })], declared));
939+
expect(typo.errors.map((e) => e.rule)).toEqual(['dashboard-filter-binding-unknown']);
940+
expect(typo.errors[0].path).toBe('dashboards[0].widgets[0].filterBindings.regoin');
941+
expect(typo.errors[0].message).toContain('(declared: region)');
942+
expect(typo.errors[0].hint).toContain('Did you mean "region"?');
943+
});
944+
924945
it('the demoted `chart-field-unknown` still RUNS at the door — on the advisory channel (#15463)', () => {
925946
// The other half of the accept-set narrowing: the exact body that used to
926947
// 422 now publishes, and the finding reaches the author on the 2xx response

‎packages/lint/src/validate-widget-bindings.test.ts‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
DASHBOARD_FILTER_FIELD_UNKNOWN,
2424
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
2525
DASHBOARD_FILTER_FIELD_NOT_INCLUDED,
26+
DASHBOARD_FILTER_BINDING_UNKNOWN,
2627
WIDGET_FILTER_FIELD_UNKNOWN,
2728
WIDGET_FILTER_FIELD_NOT_INCLUDED,
2829
WIDGET_SORTBY_UNSELECTED,
@@ -862,6 +863,131 @@ describe('validateWidgetBindings (dashboard-filter-field-unknown, issue #3365)',
862863
});
863864
});
864865

866+
describe('dashboard-filter-binding-unknown (#27445)', () => {
867+
const only = (findings: ReturnType<typeof validateWidgetBindings>) =>
868+
findings.filter((f) => f.rule === DASHBOARD_FILTER_BINDING_UNKNOWN);
869+
870+
/**
871+
* The card's probe, on a board that declares both kinds of filter: the
872+
* built-in `dateRange` and a `globalFilters` entry named `region`. `dash`
873+
* overrides the dashboard tail; `widget` the single widget.
874+
*/
875+
function stack(dash: Record<string, unknown> = {}, widget: Record<string, unknown> = {}) {
876+
return {
877+
objects: [{ name: 'deal', fields: [
878+
{ name: 'region', type: 'select' },
879+
{ name: 'sales_region', type: 'select' },
880+
{ name: 'signed_at', type: 'date' },
881+
] }],
882+
datasets: [{ name: 'deal_metrics', object: 'deal',
883+
dimensions: [{ name: 'region', field: 'region' }],
884+
measures: [{ name: 'deal_count', aggregate: 'count' }] }],
885+
dashboards: [{
886+
name: 'deal_board',
887+
dateRange: { field: 'signed_at' },
888+
globalFilters: [{ name: 'region', field: 'region', type: 'select' }],
889+
widgets: [{ id: 'kpi_deals', type: 'metric', dataset: 'deal_metrics', values: ['deal_count'], ...widget }],
890+
...dash,
891+
}],
892+
};
893+
}
894+
895+
it("refuses the card's body: a key naming no declared filter, opt-out spelling", () => {
896+
const findings = only(validateWidgetBindings(stack({}, { filterBindings: { regoin: false } })));
897+
expect(findings).toHaveLength(1);
898+
const f = findings[0];
899+
expect(f.severity).toBe('error');
900+
expect(f.path).toBe('dashboards[0].widgets[0].filterBindings.regoin');
901+
expect(f.message).toBe(
902+
'widget "kpi_deals" binds `filterBindings.regoin`, but dashboard "deal_board" declares no filter ' +
903+
'named `regoin` (declared: dateRange, region), so the binding is never read',
904+
);
905+
expect(f.hint).toBe('Rename the key to a declared filter name, or delete the entry. Did you mean "region"?');
906+
});
907+
908+
it('refuses the re-target spelling too, one finding per unknown key', () => {
909+
const findings = only(validateWidgetBindings(stack({}, {
910+
filterBindings: { region: 'sales_region', no_such_filter: 'status', other: false },
911+
})));
912+
expect(findings.map((f) => f.path)).toEqual([
913+
'dashboards[0].widgets[0].filterBindings.no_such_filter',
914+
'dashboards[0].widgets[0].filterBindings.other',
915+
]);
916+
});
917+
918+
it('accepts every key that names a declared filter, dateRange included', () => {
919+
expect(only(validateWidgetBindings(stack({}, {
920+
filterBindings: { dateRange: false, region: 'sales_region' },
921+
})))).toEqual([]);
922+
});
923+
924+
it('a globalFilter with no `name` is bound by its `field`', () => {
925+
const findings = validateWidgetBindings(stack(
926+
{ dateRange: undefined, globalFilters: [{ field: 'region', type: 'select' }] },
927+
{ filterBindings: { region: false } },
928+
));
929+
expect(only(findings)).toEqual([]);
930+
});
931+
932+
it('a custom `name` replaces the field as the key: binding the raw field is refused', () => {
933+
const findings = only(validateWidgetBindings(stack(
934+
{ globalFilters: [{ name: 'deal_region', field: 'region', type: 'select' }] },
935+
{ filterBindings: { region: false } },
936+
)));
937+
expect(findings).toHaveLength(1);
938+
expect(findings[0].message).toContain('(declared: dateRange, deal_region)');
939+
});
940+
941+
it('`dateRange` is valid only on a dashboard that declares one', () => {
942+
const findings = only(validateWidgetBindings(stack(
943+
{ dateRange: undefined },
944+
{ filterBindings: { dateRange: false } },
945+
)));
946+
expect(findings).toHaveLength(1);
947+
expect(findings[0].message).toContain('declares no filter named `dateRange` (declared: region)');
948+
expect(findings[0].hint).toBe('Declare `dateRange` on the dashboard, or delete the `dateRange` entry.');
949+
});
950+
951+
it('a dashboard with no filters at all refuses every key and says so', () => {
952+
const findings = only(validateWidgetBindings(stack(
953+
{ dateRange: undefined, globalFilters: undefined },
954+
{ filterBindings: { region: false } },
955+
)));
956+
expect(findings).toHaveLength(1);
957+
expect(findings[0].message).toContain('declares no filters, so the binding is never read');
958+
expect(findings[0].hint).toBe("Declare the filter in the dashboard's `globalFilters`, or delete the entry.");
959+
});
960+
961+
it('is judged even when the widget binds no dataset, and is not suppressible', () => {
962+
const findings = only(validateWidgetBindings(stack({}, {
963+
dataset: undefined,
964+
filterBindings: { regoin: false },
965+
suppressWarnings: [DASHBOARD_FILTER_BINDING_UNKNOWN],
966+
})));
967+
expect(findings).toHaveLength(1);
968+
});
969+
970+
it('is silent on a widget with no filterBindings', () => {
971+
expect(only(validateWidgetBindings(stack()))).toEqual([]);
972+
});
973+
974+
for (const command of ['validate', 'build'] as const) {
975+
it(`gates \`${command}\``, () => {
976+
const { errors } = splitBySeverity(runAuthoringRules(command, {
977+
normalized: stack({}, { filterBindings: { regoin: false } }),
978+
}));
979+
expect(errors.map((f) => f.rule)).toContain(DASHBOARD_FILTER_BINDING_UNKNOWN);
980+
});
981+
}
982+
983+
it('has an `os explain` entry naming the reserved key and the fix', () => {
984+
const text = explainRule(DASHBOARD_FILTER_BINDING_UNKNOWN)!.paragraphs.join('\n');
985+
expect(text).toContain('`dateRange`');
986+
expect(text).toContain('`field` when `name` is omitted');
987+
expect(text).toContain('never read');
988+
});
989+
});
990+
865991
describe('validateWidgetBindings (dashboard-filter-field-unprovisioned, issue #8340)', () => {
866992
const only = (findings: ReturnType<typeof validateWidgetBindings>) =>
867993
findings.filter((f) => f.rule === DASHBOARD_FILTER_FIELD_UNPROVISIONED);

0 commit comments

Comments
 (0)