diff --git a/.changeset/dashboard-filter-options-shorthand-deprecation-4356.md b/.changeset/dashboard-filter-options-shorthand-deprecation-4356.md new file mode 100644 index 0000000000..2ef78929d7 --- /dev/null +++ b/.changeset/dashboard-filter-options-shorthand-deprecation-4356.md @@ -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. diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index fc132f425f..abec0fac60 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -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" } + ] } ] } @@ -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 { diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 516b2958a0..9e88d2680b 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -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" }, diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json index 6186ae2a34..b4d718eb18 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json @@ -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": [ diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json index 0b8becca2f..e50f39f498 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json @@ -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"] } ], diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json index c5150e1954..1ac51e6116 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json @@ -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": [ diff --git a/examples/schema-catalog/test/plugin-dashboard-global-filters-spec.test.ts b/examples/schema-catalog/test/plugin-dashboard-global-filters-spec.test.ts new file mode 100644 index 0000000000..69c8f65970 --- /dev/null +++ b/examples/schema-catalog/test/plugin-dashboard-global-filters-spec.test.ts @@ -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); + }, + ); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx index 5e9e52bb3f..8f3b462754 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx @@ -135,7 +135,12 @@ describe('DashboardWidgetInspector — dashboard filter bindings (framework#2501 const filteredDraft = (widgetExtra: Record = {}) => ({ 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)], }); diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index 4a011e5698..120d9c46ed 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -6,12 +6,13 @@ * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { resolveDashboardFilterDefs, dashboardFilterVariableDefs, buildFilterCondition, buildWidgetScopedFilter, + resetDashboardFilterWarnings, DATE_RANGE_FILTER_NAME, DATE_RANGE_PRESETS, type DashboardFilterDef, @@ -37,15 +38,26 @@ const dateDef: DashboardFilterDef = { describe('resolveDashboardFilterDefs', () => { it('normalizes options: spec {value,label} objects AND bare-string shorthand → {value,label} pairs', () => { - const defs = resolveDashboardFilterDefs({ - globalFilters: [ - // @objectstack/spec object form — rendering this un-normalized as a - // React child crashed the Revenue Pulse dashboard (caught in dogfood). - { name: 'region', field: 'region', type: 'select', options: [{ value: 'amer', label: 'AMER' }, { value: 'emea', label: 'EMEA' }] }, - // objectui bare-string shorthand. - { name: 'status', field: 'status', type: 'select', options: ['draft', 'paid'] }, - ] as any, - }); + // The shorthand arm now also emits the #4356 deprecation warning, so this + // case captures `console.warn` rather than letting it reach the suite's + // output — the warning must be audible to AUTHORS, not to our own test log. + // Its own pins are in the `[#4356]` block at the foot of this file. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resetDashboardFilterWarnings(); + let defs; + try { + defs = resolveDashboardFilterDefs({ + globalFilters: [ + // @objectstack/spec object form — rendering this un-normalized as a + // React child crashed the Revenue Pulse dashboard (caught in dogfood). + { name: 'region', field: 'region', type: 'select', options: [{ value: 'amer', label: 'AMER' }, { value: 'emea', label: 'EMEA' }] }, + // objectui bare-string shorthand — DEPRECATED (#4356), still lifted. + { name: 'status', field: 'status', type: 'select', options: ['draft', 'paid'] }, + ] as any, + }); + } finally { + warn.mockRestore(); + } expect(defs[0].options).toEqual([ { value: 'amer', label: 'AMER' }, { value: 'emea', label: 'EMEA' }, @@ -578,3 +590,133 @@ describe('[#4165] legacy `{ preset }` declaration — ADR-0089 alias lift', () = expect(warnings.filter((m) => m.includes('LEGACY'))).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// #4356 — the bare-string `options` shorthand is DEPRECATED and says so. +// +// Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」: the +// spec stays strict and the runtime lift retires behind a deprecation window. +// This block is the warn half (Phase 1). The lift itself is unchanged — the +// LIFT pins live in `resolveDashboardFilterDefs` above and stay green in both +// directions, which is exactly what "the lift is untouched" has to mean. +// +// What each pin here is for: +// - the WARNING pin is the discriminating one: it goes red the moment the +// warning is removed, and it is what makes the window closable (ADR-0078 — +// a silent lift can never be retired, because nothing would ever show that +// the last shorthand document is gone); +// - the ONCE pin protects the render path. `resolveDashboardFilterDefs` runs +// on every dashboard render, so a warning without the memo floods the +// console per frame — and a warning that floods is a warning that gets muted; +// - the CANONICAL-SILENCE pin is a false-positive guard, and it is honestly +// NOT a discrimination proof: it passes vacuously against a build with no +// warning at all. Its value is post-change — it goes red if the warn ever +// starts firing on healthy dashboards, which would be every dashboard. +// --------------------------------------------------------------------------- +describe('[#4356] bare-string `options` shorthand — deprecation warning', () => { + /** Capture warnings without letting them reach the suite's console. */ + const resolveQuietly = (globalFilters: unknown[]) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + return { + defs: resolveDashboardFilterDefs({ globalFilters } as any), + warnings: warn.mock.calls.map((c) => String(c[0])), + }; + } finally { + warn.mockRestore(); + } + }; + + beforeEach(() => { + resetDashboardFilterWarnings(); + }); + + it('still lifts a bare string, byte-identically, AND warns', () => { + const { defs, warnings } = resolveQuietly([ + { name: 'region', field: 'region', type: 'select', options: ['EMEA', 'APAC'] }, + ]); + + // The lift is untouched — mechanically lossless, as the survey measured. + expect(defs[0].options).toEqual([ + { value: 'EMEA', label: 'EMEA' }, + { value: 'APAC', label: 'APAC' }, + ]); + + const shorthandWarnings = warnings.filter((m) => m.includes('bare-string shorthand')); + expect(shorthandWarnings).toHaveLength(1); + // Names the offending filter, the offending values, and the canonical form + // — a warning an author cannot act on is not a deprecation, it is noise. + expect(shorthandWarnings[0]).toContain('filter "region"'); + expect(shorthandWarnings[0]).toContain('"EMEA"'); + expect(shorthandWarnings[0]).toContain('{ value: "EMEA", label: "EMEA" }'); + expect(shorthandWarnings[0]).toContain('objectui#4356'); + }); + + it('warns ONCE per offending filter across repeated renders, not once per render', () => { + // The render path calls this on every frame. Three resolves, one warning. + const filters = [{ name: 'status', field: 'status', type: 'select', options: ['draft', 'paid'] }]; + const first = resolveQuietly(filters); + const second = resolveQuietly(filters); + const third = resolveQuietly(filters); + + expect(first.warnings.filter((m) => m.includes('bare-string shorthand'))).toHaveLength(1); + expect(second.warnings.filter((m) => m.includes('bare-string shorthand'))).toHaveLength(0); + expect(third.warnings.filter((m) => m.includes('bare-string shorthand'))).toHaveLength(0); + + // …and the lift keeps working on every one of them, memo or not. A dedupe + // that also suppressed the BEHAVIOUR would be a silent data change. + expect(third.defs[0].options).toEqual([ + { value: 'draft', label: 'draft' }, + { value: 'paid', label: 'paid' }, + ]); + }); + + it('warns separately for a DIFFERENT filter — the memo is not a global mute', () => { + // Keying the memo on the values alone would report the first filter and + // send the author to fix one symptom while the rest stayed silent. + const { warnings } = resolveQuietly([ + { name: 'region', field: 'region', type: 'select', options: ['EMEA'] }, + { name: 'status', field: 'status', type: 'select', options: ['draft'] }, + ]); + const shorthandWarnings = warnings.filter((m) => m.includes('bare-string shorthand')); + expect(shorthandWarnings).toHaveLength(2); + expect(shorthandWarnings[0]).toContain('filter "region"'); + expect(shorthandWarnings[1]).toContain('filter "status"'); + }); + + it('says NOTHING for canonical `{ value, label }` options', () => { + // False-positive guard: this would otherwise fire on every healthy + // dashboard in the product. + const { defs, warnings } = resolveQuietly([ + { + name: 'region', + field: 'region', + type: 'select', + options: [{ value: 'emea', label: 'EMEA' }, { value: 'apac', label: { en: 'APAC', 'zh-CN': '亚太' } }], + }, + ]); + expect(warnings.filter((m) => m.includes('bare-string shorthand'))).toEqual([]); + // The I18nLabel map survives untouched (#4032 / #4163 must-not-change). + expect(defs[0].options).toEqual([ + { value: 'emea', label: 'EMEA' }, + { value: 'apac', label: { en: 'APAC', 'zh-CN': '亚太' } }, + ]); + }); + + it('names ONLY the bare members of a MIXED array', () => { + // Partial migrations happen — the survey found one in this very repo. A + // warning that re-reported the already-canonical members would send the + // author back to options they had just fixed. + const { defs, warnings } = resolveQuietly([ + { name: 'stage', field: 'stage', type: 'select', options: [{ value: 'won', label: 'Won' }, 'lost'] }, + ]); + const shorthandWarnings = warnings.filter((m) => m.includes('bare-string shorthand')); + expect(shorthandWarnings).toHaveLength(1); + expect(shorthandWarnings[0]).toContain('"lost"'); + expect(shorthandWarnings[0]).not.toContain('"won"'); + expect(defs[0].options).toEqual([ + { value: 'won', label: 'Won' }, + { value: 'lost', label: 'lost' }, + ]); + }); +}); diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 70c2d0baa1..c37eab9674 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -55,9 +55,13 @@ export interface DashboardFilterDef { type: 'text' | 'select' | 'date' | 'number' | 'lookup' | 'dateRange'; /** * Static options, NORMALIZED to `{ value, label }` pairs by - * `resolveDashboardFilterDefs` — authors may write either the - * @objectstack/spec object form (`{ value, label }`) or the bare-string - * shorthand; consumers always see the object form. + * `resolveDashboardFilterDefs` — consumers always see the object form. + * + * The canonical authoring form is @objectstack/spec's `{ value, label }` + * pair, and it is the ONLY one the platform accepts at publish. A bare-string + * shorthand in a STORED document is still lifted here, with a deprecation + * warning, on the objectstack#7917 retirement schedule — see + * `normalizeFilterOptions`. Do not author a new one. * * The PAIR SHAPE is normalized; the label's own vocabulary is not. `label` * is `I18nLabel` in `GlobalFilterSchema.options[]` too, and it reaches the @@ -190,6 +194,36 @@ function warnDateFilter(message: string): void { if (typeof console !== 'undefined') console.warn(`[dashboard-filters] ${message}`); } +/** + * Dev-mode gate, matching `actions/actionKeys.ts` — a deprecation warning that + * floods a production console is a warning that gets muted. + */ +const isDev = (): boolean => + (globalThis as { process?: { env?: Record } }).process?.env?.NODE_ENV !== + 'production'; + +/** + * Warn-once memo for the bare-string `options` shorthand (objectui#4356). + * + * Keyed by filter NAME **and** the offending values, deliberately — the same + * reasoning `warnOnUnknownActionKeys` records for its own memo. Keying on the + * name alone would report the first dashboard carrying a shorthand `status` + * filter and stay silent about every other one, sending the author to fix one + * symptom; keying on the values alone would collapse two genuinely different + * filters that happen to share an option list. The memo is bounded by the + * number of authored filters either way. + * + * This lives at module scope because `resolveDashboardFilterDefs` runs on every + * dashboard render — per-call state would warn once per frame, which is the + * flood the dedupe exists to prevent. + */ +const warnedShorthandOptions = new Set(); + +/** Reset the shorthand-options warn-once memo. Exported for tests. */ +export function resetDashboardFilterWarnings(): void { + warnedShorthandOptions.clear(); +} + /** * Apply the ADR-0089 legacy-alias lift to ONE stored `globalFilters` entry, and * say so out loud when it fires (objectui#4165). @@ -309,8 +343,37 @@ function normalizeDateDefault(type: DashboardFilterDef['type'], defaultValue: un * Normalize a filter's static `options` declaration to `{ value, label }` * pairs. The @objectstack/spec `GlobalFilterSchema.options` form is * `{ value, label }` objects; the bare-string shorthand (`options: ['EMEA', …]`) - * is also accepted. Rendering an un-normalized option crashes React — this is - * the single place both shapes converge. + * is still lifted, but is DEPRECATED and now says so out loud. Rendering an + * un-normalized option crashes React — this is the single place both shapes + * converge. + * + * ## The shorthand's deprecation (objectui#4356, objectstack#7917) + * + * Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」: + * option ② — **the spec stays strict; the runtime bare-string lift retires + * behind a deprecation window sized by a stored-dashboard survey.** So a + * document spelling `options: ['EMEA']` renders here and is refused the moment + * it reaches the platform's validation — the "one strict contract beats N + * dialects" divergence AGENTS.md #0.1 names, with the renderer's tolerance + * acting as a second de-facto contract. + * + * This is the WARN half of that window (Phase 1). The lift itself is unchanged + * and remains mechanically lossless (`'EMEA'` → `{ value: 'EMEA', label: + * 'EMEA' }`), because stored dashboards carry the shorthand and dropping it + * silently would turn a rendering filter into an empty one. Removal (Phase 2) + * is scheduled on objectstack#7917, earliest one minor release after this ships + * and not before the live-tenant channel has actually been queried. + * + * The warning is not decoration: a silent lift can never be retired, because + * nothing would ever show that the last shorthand document is gone (ADR-0078 — + * nothing silently inert). It is the same reasoning `liftLegacyFilterDeclaration` + * records above, for the sibling alias. + * + * Phase 0 shipped in the same PR: objectui's own docs, its `plugin-dashboard` + * README and its schema-catalog corpus stopped TEACHING the shorthand, so the + * stored population is no longer growing while this warning asks authors to + * migrate. Warning authors while the docs still taught the form would have been + * a contradiction users report as a bug. * * ## What is normalized, and what is deliberately NOT (objectui#4032 / #4163) * @@ -336,9 +399,12 @@ function normalizeDateDefault(type: DashboardFilterDef['type'], defaultValue: un */ function normalizeFilterOptions( options: unknown, + filterName: string, ): Array<{ value: string; label: string | I18nLabel }> | undefined { if (!Array.isArray(options) || options.length === 0) return undefined; const normalized: Array<{ value: string; label: string | I18nLabel }> = []; + /** Every bare-string member, in authored order — one warning names them all. */ + const shorthand: string[] = []; for (const o of options) { if (o === null || o === undefined) continue; if (typeof o === 'object') { @@ -351,12 +417,41 @@ function normalizeFilterOptions( label: (typeof label === 'string' && label) || isMap ? label : String(value), }); } else { + shorthand.push(String(o)); normalized.push({ value: String(o), label: String(o) }); } } + if (shorthand.length > 0) warnShorthandOptions(filterName, shorthand); return normalized.length > 0 ? normalized : undefined; } +/** + * Say the deprecated shorthand out loud — once per offending filter per + * session, naming the filter and printing the canonical replacement. + * + * Collected per FILTER rather than per option: a filter declaring + * `['EMEA', 'APAC', 'AMER']` is one authoring mistake in one place, so it earns + * one warning carrying all three values, not three warnings the author has to + * reassemble. A MIXED array (`[{ value: 'won', … }, 'lost']`) names only the + * bare members, which are the ones that need rewriting — partial migrations + * happen and a warning that re-reports the already-canonical members is noise. + */ +function warnShorthandOptions(filterName: string, shorthand: string[]): void { + if (!isDev()) return; + const memo = `${filterName}:${shorthand.join(',')}`; + if (warnedShorthandOptions.has(memo)) return; + warnedShorthandOptions.add(memo); + const canonical = shorthand.map((v) => `{ value: ${JSON.stringify(v)}, label: ${JSON.stringify(v)} }`).join(', '); + warnDateFilter( + `filter "${filterName}": \`options\` carries the bare-string shorthand ` + + `(${shorthand.map((v) => JSON.stringify(v)).join(', ')}), which @objectstack/spec's ` + + `\`GlobalFilterSchema\` REFUSES at publish — a dashboard authored this way renders here ` + + `and is rejected the moment it reaches the platform (objectui#4356). Rewrite the stored ` + + `dashboard to the canonical pair form: [${canonical}]. Still lifted here for already-` + + `persisted dashboards; the lift is removed on the objectstack#7917 schedule.`, + ); +} + /** * Normalize a dashboard schema's filter declarations into a flat list of * filter definitions. The built-in `dateRange` (when declared) comes first @@ -397,7 +492,17 @@ export function resolveDashboardFilterDefs( field: f.field, label: f.label, type, - options: normalizeFilterOptions(f.options), + // `name` is the identifying context the deprecation warning needs, and + // the local above already resolved it — nothing new is threaded through a + // public signature for it. `normalizeFilterOptions` is module-private, so + // widening ITS parameter list is not a contract move. + // + // (Deliberately not restating that local's expression here: the + // column-identity ratchet in `__tests__/column-identity.ratchet.test.ts` + // is a LINE-LEVEL scanner, so a comment quoting it reads as a second + // dual read and fails the count — a false positive worth avoiding rather + // than absorbing into the inventory, which would mask a future real one.) + options: normalizeFilterOptions(f.options, name), optionsFrom: f.optionsFrom, // framework#4475 — same preset-name lifting the built-in `dateRange` // above already does; see normalizeDateDefault for why a bare string is diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index c20a22e4ae..5c052d17c4 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -219,7 +219,14 @@ into each bound widget's inline query (`AND`-combined with the widget's own "field": "region", // default binding target "label": "Region", "type": "select", // text | select | date | number | lookup - "options": ["EMEA", "APAC", "AMER"] + // Canonical @objectstack/spec pair form — the only form the platform + // accepts at publish. The bare-string shorthand (["EMEA", …]) is + // deprecated: still lifted at runtime, now warns (objectui#4356). + "options": [ + { "value": "EMEA", "label": "EMEA" }, + { "value": "APAC", "label": "APAC" }, + { "value": "AMER", "label": "AMER" } + ] // or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region" } } ], diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx index 044f766df4..b683ba9a03 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx @@ -162,7 +162,16 @@ describe('DashboardFilterBar — inline per-locale filter labels (#4032 / #4163) field: 'stage', type: 'select', label: 'Stage', - options: [{ value: 'won', label: 'Won' }, 'lost'], + // Both options in @objectstack/spec's `{ value, label }` pair form. + // This case used to spell the second one as the bare string 'lost' to + // also exercise a MIXED array; that shorthand is deprecated and now + // warns (objectui#4356), and the mixed-array lift has its own pin in + // `packages/core/src/utils/__tests__/dashboard-filters.test.ts` + // (`names ONLY the bare members of a MIXED array`), which asserts + // this exact `{ value: 'lost', label: 'lost' }` result. What THIS + // case is for — a plain-string label surviving the i18n path + // untouched — is unchanged. + options: [{ value: 'won', label: 'Won' }, { value: 'lost', label: 'lost' }], }, ], } as never);