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
17 changes: 17 additions & 0 deletions .changeset/dashboard-filter-options-shorthand-deprecation-4356.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@object-ui/core": minor
---

fix(core): bare-string filter options — docs/examples stop teaching it, the runtime lift warns (objectui#4356)

`globalFilters[].options` had two de-facto contracts. `@objectstack/spec`'s `GlobalFilterSchema` accepts only `{ value, label }` pairs, while `normalizeFilterOptions` also lifted a bare-string shorthand (`options: ['EMEA', 'APAC']`) — so a dashboard authored that way rendered correctly in objectui and was refused the moment it reached the platform's validation. That is the "one strict contract beats N dialects" divergence AGENTS.md #0.1 names, with the renderer's tolerance hiding the producer's bug instead of surfacing it.

Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」: **the spec stays strict; the runtime lift retires behind a deprecation window sized by a stored-dashboard survey.** This is Phases 0 and 1 of that window, shipped together. Phase 2 (removing the lift) is scheduled on objectstack#7917 and is deliberately not here.

**Phase 1 — the lift now says so out loud.** `normalizeFilterOptions` still lifts a bare string, unchanged and mechanically lossless (`'EMEA'` becomes `{ value: 'EMEA', label: 'EMEA' }`), because stored dashboards carry the shorthand and dropping it silently would turn a rendering filter into an empty one. It now also logs a deprecation warning naming the offending filter, quoting the offending values, and printing the canonical replacement. The warning fires **once per offending filter per session** — `resolveDashboardFilterDefs` runs on every dashboard render, and a warning that floods the console is a warning that gets muted — and it is dev-mode only, matching the `warnOnDeprecatedObjectParams` convention in `actions/actionKeys.ts`. It does not fire for canonical object options, and a mixed array names only its bare members, since partial migrations happen. A silent lift can never be retired, because nothing would ever show that the last shorthand document is gone (ADR-0078).

**Phase 0 — objectui stopped teaching the form.** The stored-dashboard survey on objectstack#7917 found the shorthand's source: objectui's own docs and its schema-catalog corpus — which the catalog's `package.json` declares an AI RAG/few-shot retrieval source — still authored it, so the stored population was still growing. All seven non-test occurrences are corrected to the pair form: `content/docs/guide/dashboard-filters.md` (a code block **and** a prose passage that presented the shorthand as an equal alternative), `content/docs/plugins/plugin-dashboard.mdx`, `packages/plugin-dashboard/README.md`, and the three `examples/schema-catalog` `filtered-dashboard*.json` entries. Warning authors while the docs still taught the form would have been a contradiction users report as a bug.

**Guardrail.** The schema catalog previously asserted only that its entries were structurally well-formed and rendered without throwing — which is exactly how a spec-invalid example got in. Every `globalFilters[]` entry in every `plugin-dashboard` catalog example is now parsed with the real `@objectstack/spec` `GlobalFilterSchema`, with a non-vacuity control so a broken sweep cannot read as green.

New export: `resetDashboardFilterWarnings()`, the warn-once memo reset, matching `resetActionKeyWarnings`. Graded `minor` for that additive export — measured, the emitted `.d.ts` gains exactly one declaration and narrows nothing.
26 changes: 20 additions & 6 deletions content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ Add a `globalFilters` entry. Each entry renders one control in the filter bar:
"field": "region",
"label": "Region",
"type": "select",
"options": ["EMEA", "APAC", "AMER"]
"options": [
{ "value": "EMEA", "label": "EMEA" },
{ "value": "APAC", "label": "APAC" },
{ "value": "AMER", "label": "AMER" }
]
}
]
}
Expand Down Expand Up @@ -128,11 +132,21 @@ and the runtime logs a `console.warn` naming the filter and the value. It is
deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and
the widget would render a perfectly healthy-looking `0`.

Static `options` accept the `@objectstack/spec` object form
(`{ "value": "amer", "label": "AMER" }` — canonical, and what the spec
validates) or a bare-string shorthand (`["EMEA", "APAC"]`); the runtime
normalizes both to value/label pairs. Options can also be fetched from an
object at runtime:
Static `options` are `@objectstack/spec` object pairs —
`{ "value": "amer", "label": "AMER" }`. This is the only form the platform
accepts: a dashboard is validated against `GlobalFilterSchema` when it is
published, and anything else is refused there.

> **Deprecated: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is
> still lifted by the runtime to `{ "value": "EMEA", "label": "EMEA" }` pairs so
> that already-stored dashboards keep rendering, but it now logs a deprecation
> warning naming the filter, and it is scheduled for removal
> ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)).
> Write the object form. The lift is mechanically lossless, so migrating a
> stored dashboard is a direct rewrite of each string `X` to
> `{ "value": "X", "label": "X" }`.

Options can also be fetched from an object at runtime:

```json
{
Expand Down
9 changes: 8 additions & 1 deletion content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,14 @@ the widget's own `filter`).
"type": "dashboard",
"dateRange": { "field": "created_at", "defaultRange": "last_30_days", "allowCustomRange": true },
"globalFilters": [
{ "name": "region", "field": "region", "label": "Region", "type": "select", "options": ["EMEA", "APAC", "AMER"] }
{
"name": "region", "field": "region", "label": "Region", "type": "select",
"options": [
{ "value": "EMEA", "label": "EMEA" },
{ "value": "APAC", "label": "APAC" },
{ "value": "AMER", "label": "AMER" }
]
}
],
"widgets": [
{ "id": "w1", "type": "bar", "object": "invoices", "aggregate": "count" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
"field": "region",
"label": "Region",
"type": "select",
"options": ["EMEA", "APAC", "AMER"]
"options": [
{ "value": "EMEA", "label": "EMEA" },
{ "value": "APAC", "label": "APAC" },
{ "value": "AMER", "label": "AMER" }
]
}
],
"widgets": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
"field": "status",
"label": "Status",
"type": "select",
"options": ["draft", "sent", "paid", "void"],
"options": [
{ "value": "draft", "label": "draft" },
{ "value": "sent", "label": "sent" },
{ "value": "paid", "label": "paid" },
{ "value": "void", "label": "void" }
],
"targetWidgets": ["invoices_by_region", "invoices_recent"]
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
"field": "region",
"label": "Region",
"type": "select",
"options": ["EMEA", "APAC", "AMER"]
"options": [
{ "value": "EMEA", "label": "EMEA" },
{ "value": "APAC", "label": "APAC" },
{ "value": "AMER", "label": "AMER" }
]
}
],
"widgets": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4356 — the `plugin-dashboard` catalog entries' `globalFilters` are
* validated against the REAL `@objectstack/spec` schema, not merely rendered.
*
* ## Why this guardrail exists
*
* This catalog is not a test fixture directory. Its own `package.json` calls it
* the "single source of truth for example schemas consumed by the docs site,
* smoke tests, and **AI few-shot retrieval**", and `test/fields-form-hosted.
* test.tsx` already records the objectui#3910 lesson in those words: *"these
* examples are the docs site's field demos and a few-shot retrieval source for
* AI authors, so what they show is what gets copied."*
*
* Until this file, the catalog's only assertions were structural (`smoke.test.
* tsx`: every entry is an object with a non-empty `type`) and render-without-
* throw. Both are satisfied by a schema the platform REFUSES at publish — which
* is exactly how three entries came to ship the bare-string `options` shorthand
* (`["EMEA", "APAC", "AMER"]`) that `GlobalFilterSchema` rejects. The stored-
* dashboard survey on objectstack#7917 found them and named this guardrail as
* the fix: an AI author retrieving from this corpus was being taught a form the
* platform's own door refuses.
*
* ## Why this asserts `GlobalFilterSchema` and not `DashboardSchema`
*
* The survey suggested parsing each entry with `DashboardSchema` and requiring
* `safeParse` to succeed. **Measured, that is not implementable, and the reason
* is not the shorthand.** All 9 `plugin-dashboard` entries are refused by
* `DashboardSchema` today, and they stay refused after the shorthand is fixed:
*
* - these are objectui **SDUI component** schemas (`{ "type": "dashboard",
* "title": …, "columns": … }`), not stored platform metadata documents, so
* they carry no `name` / `label` metadata identity keys — 2 issues per entry
* before any widget is read;
* - most of their widgets use the pre-ADR-0021 inline analytics shape
* (`object` + `categoryField` + `aggregate`), which `@objectstack/spec` 17
* removed in favour of `dataset` + `dimensions` + `values`.
*
* A `DashboardSchema.safeParse` assertion would therefore be permanently red,
* and making it green would mean rewriting all 9 examples into platform-metadata
* shape — a far larger change than this card, and one that would silently drop
* the inline-analytics form the docs still teach elsewhere. That divergence is
* real and is filed separately; it is deliberately NOT smuggled in here.
*
* So the guardrail is pinned at the exact sub-schema that owns the surface this
* card governs: `GlobalFilterSchema`, the spec's own definition of a
* `globalFilters[]` entry, applied to every such entry in every
* `plugin-dashboard` example. That is a real spec validation — the same schema
* the platform runs — over the property that actually regressed.
*/

import { describe, it, expect } from 'vitest';
import { GlobalFilterSchema } from '@objectstack/spec/ui';
import { examplesByCategory } from '../src/index.js';

const entries = examplesByCategory('plugin-dashboard');

/** Every `globalFilters[]` entry in the category, tagged with its origin. */
const filters = entries.flatMap((example) => {
const globalFilters = (example.schema as { globalFilters?: unknown }).globalFilters;
if (!Array.isArray(globalFilters)) return [];
return globalFilters.map((filter, index) => ({ id: example.id, index, filter }));
});

describe('schema-catalog plugin-dashboard — globalFilters validate against @objectstack/spec', () => {
it('the category is non-empty', () => {
expect(entries.length).toBeGreaterThan(0);
});

/**
* NON-VACUITY CONTROL — the assertion below is `it.each` over `filters`, and
* `it.each([])` reports NOTHING rather than failing. Without this pin, a
* refactor that broke the collection (a renamed category, a changed registry
* shape) would turn the guardrail silently green while validating zero
* filters. The survey applied exactly this discipline to its own sweep, for
* exactly this reason.
*/
it('the sweep actually reaches filters', () => {
expect(filters.length).toBeGreaterThan(0);
expect(new Set(filters.map((f) => f.id)).size).toBeGreaterThan(1);
});

it.each(filters.map((f) => [`${f.id} globalFilters[${f.index}]`, f.filter]))(
'%s is accepted by GlobalFilterSchema',
(_label, filter) => {
const result = GlobalFilterSchema.safeParse(filter);
// Surface the spec's own message — a bare `.success` boolean tells the
// next reader that something is wrong and nothing about what.
const issues = result.success
? []
: result.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`);
expect(issues).toEqual([]);
expect(result.success).toBe(true);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ describe('DashboardWidgetInspector — dashboard filter bindings (framework#2501
const filteredDraft = (widgetExtra: Record<string, unknown> = {}) => ({
dateRange: { field: 'created_at', defaultRange: 'last_30_days' },
globalFilters: [
{ name: 'region', field: 'region', label: 'Region', type: 'select', options: ['EMEA'] },
// Options in @objectstack/spec's `{ value, label }` pair form. Nothing in
// this suite reads the list — it is scenery for the BINDINGS under test —
// so the deprecated bare-string shorthand it used to spell bought nothing
// and now warns (objectui#4356). Its coverage is
// `packages/core/src/utils/__tests__/dashboard-filters.test.ts`.
{ name: 'region', field: 'region', label: 'Region', type: 'select', options: [{ value: 'EMEA', label: 'EMEA' }] },
],
widgets: [widget(widgetExtra)],
});
Expand Down
Loading
Loading