From bc4ea976e5e142ffbeb4583326298fc58eaf69ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:45:12 +0000 Subject: [PATCH 1/4] feat(core)!: retire the bare-string `globalFilters[].options` lift (objectui#4356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeFilterOptions` no longer lifts a non-object `options` member to a `{ value, label }` pair: the member yields no option, a shorthand-only filter resolves with no `options`, and a mixed array keeps only its object members. The Phase 1 deprecation warning (PR #4601), whose text promised the lift, goes with the arm; a dev-mode, once-per-filter warning names the dropped members and the rewrite instead (ADR-0078 section 4), so `resetDashboardFilterWarnings` keeps its memo and stays exported. Rulings: objectstack#7917 「7917 ②」 (2026-08-12) and 「objectstack#7917 不考虑现有数据」 (2026-09-02). The docs callout and the `GlobalFilterSchema` docblock in @object-ui/types stop describing the lift. Changeset: core minor. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm --- .../retire-options-shorthand-lift-4356.md | 13 ++ content/docs/guide/dashboard-filters.md | 15 +- .../utils/__tests__/dashboard-filters.test.ts | 161 ++++++++++-------- packages/core/src/utils/dashboard-filters.ts | 133 ++++++++------- packages/types/src/zod/complex.zod.ts | 23 ++- 5 files changed, 197 insertions(+), 148 deletions(-) create mode 100644 .changeset/retire-options-shorthand-lift-4356.md diff --git a/.changeset/retire-options-shorthand-lift-4356.md b/.changeset/retire-options-shorthand-lift-4356.md new file mode 100644 index 0000000000..bde5c96b0c --- /dev/null +++ b/.changeset/retire-options-shorthand-lift-4356.md @@ -0,0 +1,13 @@ +--- +'@object-ui/core': minor +--- + +feat(core)!: bare-string `globalFilters[].options` is no longer lifted; use `{ value, label }` objects, the spec's form (objectui#4356). + +**Breaking — published runtime behaviour removed; the bump stays `minor` under this repository's version policy, with the break stated here.** `resolveDashboardFilterDefs` used to lift a bare-string member of a dashboard's `globalFilters[].options` — `'EMEA'` became `{ value: 'EMEA', label: 'EMEA' }` — and, since objectui PR #4601, logged a deprecation warning while doing so. That lift is gone. A member that is not a `{ value, label }` object (a string, a number, a boolean) now yields no option: a shorthand-only filter resolves with no `options`, and a mixed array keeps only its object members. The runtime reads the document exactly as `@objectstack/spec`'s `GlobalFilterSchema` does, which has always refused the shorthand at publish. + +**Why now.** Maintainer ruling on objectstack#7917 (2026-08-12, verbatim 「7917 ②」): the spec stays strict and the runtime lift retires. The retirement window closed on the 2026-09-02 ruling, verbatim 「objectstack#7917 不考虑现有数据」 — Phase 2 proceeds on release cadence alone, with no stored-dashboard survey and no migration entry. Phase 0 (objectui stopped teaching the form) and Phase 1 (the deprecation warning) shipped in PR #4601. + +**What a stored dashboard sees.** A `select` filter whose options were all bare strings renders with an empty option list. In development a single `console.warn` — once per offending filter per session, the same memo the deprecation warning used — names the filter, the dropped members and the rewrite; it no longer promises a lift. Rewrite each string `X` as `{ "value": "X", "label": "X" }`. + +`resetDashboardFilterWarnings()` stays exported: the warn-once memo it clears now guards the dropped-member warning instead of the deprecation warning. diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index 00c5a43362..c4517de847 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -180,14 +180,13 @@ published, and anything else is refused there. objectui's own validator (`@object-ui/types`, which `objectui validate` runs) uses the same spec schema, so it refuses the same documents before they reach the platform. -> **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" }`. +> **Not accepted: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is +> refused by `GlobalFilterSchema` at publish, and the runtime no longer lifts it +> either ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)): +> a bare-string member yields no option, so a stored dashboard authored this way +> renders that filter with an empty option list, and in development a single +> `console.warn` names the filter and the dropped members. Rewrite each string +> `X` as `{ "value": "X", "label": "X" }`. Options can also be fetched from an object at runtime: diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index 5cf2dc0cc7..7ebea4f1b7 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -38,11 +38,11 @@ const dateDef: DashboardFilterDef = { }; describe('resolveDashboardFilterDefs', () => { - it('normalizes options: spec {value,label} objects AND bare-string shorthand → {value,label} pairs', () => { - // 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. + it('normalizes spec {value,label} objects, and DROPS a bare-string member rather than lifting it (objectui#4356)', () => { + // The dropped member is reported once by a dev-mode `console.warn`, so this + // case captures it 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; @@ -52,7 +52,7 @@ describe('resolveDashboardFilterDefs', () => { // @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. + // The RETIRED bare-string shorthand (#4356) — yields no option at all. { name: 'status', field: 'status', type: 'select', options: ['draft', 'paid'] }, ] as any, }); @@ -63,10 +63,7 @@ describe('resolveDashboardFilterDefs', () => { { value: 'amer', label: 'AMER' }, { value: 'emea', label: 'EMEA' }, ]); - expect(defs[1].options).toEqual([ - { value: 'draft', label: 'draft' }, - { value: 'paid', label: 'paid' }, - ]); + expect(defs[1].options).toBeUndefined(); }); it('maps dateRange to the reserved name with a created_at field default', () => { @@ -632,26 +629,35 @@ describe('[#4165] legacy `{ preset }` declaration — ADR-0089 alias lift', () = // --------------------------------------------------------------------------- // #4356 — the bare-string `options` shorthand is DEPRECATED and says so. // +// --------------------------------------------------------------------------- +// #4356 — the bare-string `options` shorthand is RETIRED: dropped, not lifted. +// // 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. +// spec stays strict and the runtime lift retires. PR #4601 shipped the warn half +// (Phase 1); the window closed on the 2026-09-02 ruling, verbatim +// 「objectstack#7917 不考虑现有数据」, and this block pins the end state +// (Phase 2): a non-object member yields no option, a shorthand-only filter +// resolves with no `options`, and a mixed array keeps only its object members. // // 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 DROP pins are the discriminating ones: each went red against the +// Phase 1 build (which lifted `'EMEA'` to `{ value: 'EMEA', label: 'EMEA' }`) +// before the arm was removed — red-first, quoted in the landing PR; +// - the WARNING pin keeps the skip audible (ADR-0078 §4 — where the runtime +// skips an instance it emits a dev-mode diagnostic rather than swallowing +// it): a stored dashboard is the one document no author-time gate re-reads, +// and an empty select with no line in the console reads as "no data". The +// warning names the filter, the dropped members and the rewrite, and it +// promises NO lift — the Phase 1 sentence "Still lifted here" retired with +// the arm, which the negative pin below guards; // - 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. +// - the CANONICAL-SILENCE pin is a false-positive guard, honestly NOT a +// discrimination proof: it passes vacuously against a build with no warning +// at all. It goes red if the warn ever fires on healthy dashboards. // --------------------------------------------------------------------------- -describe('[#4356] bare-string `options` shorthand — deprecation warning', () => { +describe('[#4356] bare-string `options` shorthand — retired: dropped, not lifted', () => { /** Capture warnings without letting them reach the suite's console. */ const resolveQuietly = (globalFilters: unknown[]) => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -665,48 +671,78 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () = } }; + /** The one warning this block is about, picked by its card, not its prose. */ + const shorthandWarnings = (warnings: string[]) => warnings.filter((m) => m.includes('objectui#4356')); + beforeEach(() => { resetDashboardFilterWarnings(); }); - it('still lifts a bare string, byte-identically, AND warns', () => { + it('DROPS a bare string: a shorthand-only filter resolves with no `options`, and says so', () => { 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' }, + // Nothing is lifted — the spec's reading of the document, no dialect beside it. + expect(defs).toHaveLength(1); + expect(defs[0].options).toBeUndefined(); + + const w = shorthandWarnings(warnings); + expect(w).toHaveLength(1); + // Names the filter, the dropped members and the rewrite — a warning an + // author cannot act on is noise. + expect(w[0]).toContain('filter "region"'); + expect(w[0]).toContain('"EMEA"'); + expect(w[0]).toContain('"APAC"'); + expect(w[0]).toContain('{ value: "EMEA", label: "EMEA" }'); + // …and promises no lift. The Phase 1 text said "Still lifted here"; that + // sentence is the one thing the ruling forbids this warning to carry. + expect(w[0]).not.toContain('lifted here'); + }); + + it('keeps ONLY the object members of a MIXED array, and names only the dropped ones', () => { + // Partial migrations happen — the survey found one in this very repo. The + // object member renders; re-reporting it would send the author back to an + // option they had just fixed. + const { defs, warnings } = resolveQuietly([ + { name: 'stage', field: 'stage', type: 'select', options: [{ value: 'won', label: 'Won' }, 'lost'] }, ]); + expect(defs[0].options).toEqual([{ value: 'won', label: 'Won' }]); + const w = shorthandWarnings(warnings); + expect(w).toHaveLength(1); + expect(w[0]).toContain('"lost"'); + expect(w[0]).not.toContain('"won"'); + }); - 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('drops EVERY non-object member — a number and a boolean, not only a string', () => { + // Phase 1 lifted every non-nullish primitive through `String(o)`; the + // retirement removes the whole arm, not the string case of it. + const { defs, warnings } = resolveQuietly([ + { name: 'tier', field: 'tier', type: 'select', options: [42, true, { value: 'gold', label: 'Gold' }] }, + ]); + expect(defs[0].options).toEqual([{ value: 'gold', label: 'Gold' }]); + const w = shorthandWarnings(warnings); + expect(w).toHaveLength(1); + expect(w[0]).toContain('42'); + expect(w[0]).toContain('true'); }); - it('warns ONCE per offending filter across repeated renders, not once per render', () => { + it('warns ONCE per offending filter across repeated renders, and drops on every one of them', () => { // 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); + expect(shorthandWarnings(first.warnings)).toHaveLength(1); + expect(shorthandWarnings(second.warnings)).toHaveLength(0); + expect(shorthandWarnings(third.warnings)).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' }, - ]); + // The memo mutes the WARNING, never the behaviour: the member is dropped on + // every resolve, memo or not — a dedupe that also changed the output would + // be a silent data change. + expect(first.defs[0].options).toBeUndefined(); + expect(third.defs[0].options).toBeUndefined(); }); it('warns separately for a DIFFERENT filter — the memo is not a global mute', () => { @@ -716,13 +752,13 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () = { 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"'); + const w = shorthandWarnings(warnings); + expect(w).toHaveLength(2); + expect(w[0]).toContain('filter "region"'); + expect(w[1]).toContain('filter "status"'); }); - it('says NOTHING for canonical `{ value, label }` options', () => { + it('says NOTHING for canonical `{ value, label }` options, and keeps an I18nLabel map intact', () => { // False-positive guard: this would otherwise fire on every healthy // dashboard in the product. const { defs, warnings } = resolveQuietly([ @@ -733,7 +769,7 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () = options: [{ value: 'emea', label: 'EMEA' }, { value: 'apac', label: { en: 'APAC', 'zh-CN': '亚太' } }], }, ]); - expect(warnings.filter((m) => m.includes('bare-string shorthand'))).toEqual([]); + expect(shorthandWarnings(warnings)).toEqual([]); // The I18nLabel map survives untouched (#4032 / #4163 must-not-change). expect(defs[0].options).toEqual([ { value: 'emea', label: 'EMEA' }, @@ -741,20 +777,11 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () = ]); }); - 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' }, - ]); + it('`resetDashboardFilterWarnings` re-arms the memo — the export keeps its job', () => { + const filters = [{ name: 'region', field: 'region', type: 'select', options: ['EMEA'] }]; + expect(shorthandWarnings(resolveQuietly(filters).warnings)).toHaveLength(1); + expect(shorthandWarnings(resolveQuietly(filters).warnings)).toHaveLength(0); + resetDashboardFilterWarnings(); + expect(shorthandWarnings(resolveQuietly(filters).warnings)).toHaveLength(1); }); }); diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 94bde5c484..50e44ea29e 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -111,10 +111,11 @@ export interface DashboardFilterDef { * `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. + * pair, and it is the ONLY one the platform accepts at publish. The + * bare-string shorthand a STORED document may still carry is NOT lifted + * (objectui#4356, retired on the objectstack#7917 option-② schedule): such + * a member yields no option, and `normalizeFilterOptions` says so once. Do + * not author one; rewrite `"X"` as `{ "value": "X", "label": "X" }`. * * The PAIR SHAPE is normalized; the label's own vocabulary is not. `label` * is `I18nLabel` in `GlobalFilterSchema.options[]` too, and it reaches the @@ -248,15 +249,15 @@ function warnDateFilter(message: string): void { } /** - * Dev-mode gate, matching `actions/actionKeys.ts` — a deprecation warning that - * floods a production console is a warning that gets muted. + * Dev-mode gate, matching `actions/actionKeys.ts` — a 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). + * Warn-once memo for the DROPPED bare-string `options` members (objectui#4356). * * Keyed by filter NAME **and** the offending values, deliberately — the same * reasoning `warnOnUnknownActionKeys` records for its own memo. Keying on the @@ -272,7 +273,13 @@ const isDev = (): boolean => */ const warnedShorthandOptions = new Set(); -/** Reset the shorthand-options warn-once memo. Exported for tests. */ +/** + * Reset the dropped-shorthand warn-once memo. Exported for tests. + * + * Kept through the objectui#4356 retirement: the memo it clears outlived the + * deprecation warning, because the warning that replaced it (a member was + * DROPPED, not lifted) dedupes on the same key for the same render-path reason. + */ export function resetDashboardFilterWarnings(): void { warnedShorthandOptions.clear(); } @@ -394,45 +401,40 @@ 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 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. + * pairs — the @objectstack/spec `GlobalFilterSchema.options` form, and the + * only form this function emits. Rendering an un-normalized option crashes + * React, so every member that reaches a renderer passes through here. * - * ## The shorthand's deprecation (objectui#4356, objectstack#7917) + * ## The bare-string shorthand is RETIRED (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. + * option ② — **the spec stays strict; the runtime bare-string lift retires.** + * The window closed on the maintainer's 2026-09-02 ruling, verbatim + * 「objectstack#7917 不考虑现有数据」: Phase 2 proceeds on release cadence + * alone, with no live-tenant survey and no migration entry. + * + * So `options: ['EMEA']` — which the Phase 1 build (PR #4601) lifted to + * `{ value: 'EMEA', label: 'EMEA' }` under a deprecation warning — now yields + * NO option. A member that is not an object is skipped, exactly as a nullish + * member or an object with no `value` already was: a shorthand-only filter + * resolves with no `options`, and a mixed array keeps only its object members. + * The runtime reads the document the way the spec does, which is the end + * state option ② named — one strict contract, no renderer dialect beside it + * (AGENTS.md #0.1). ⛔ No lift, alias, fallback or migration returns here. + * + * The skip is not silent. `warnDroppedShorthandOptions` names the filter, the + * dropped members and the rewrite, once per filter per session — ADR-0078 §4: + * where the runtime skips an instance it emits a dev-mode diagnostic rather + * than swallowing it, and a STORED dashboard is the one document no author-time + * gate ever re-reads, so an empty select with nothing in the console would read + * as "no data". What that warning must NOT do is promise a lift: the Phase 1 + * text said "still lifted here", and that sentence retired with the arm. * * ## What is normalized, and what is deliberately NOT (objectui#4032 / #4163) * - * The PAIR SHAPE is normalized (`value` stringified, a bare string lifted to a - * pair). The LABEL's authoring vocabulary is carried through untouched, because - * `label` is `I18nLabel` — a string OR an inline per-locale map. + * The PAIR SHAPE is normalized (`value` stringified). The LABEL's authoring + * vocabulary is carried through untouched, because `label` is `I18nLabel` — a + * string OR an inline per-locale map. * * This line used to read: * @@ -456,8 +458,8 @@ function normalizeFilterOptions( ): 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[] = []; + /** Every non-object member, in authored order — one warning names them all. */ + const dropped: unknown[] = []; for (const o of options) { if (o === null || o === undefined) continue; if (typeof o === 'object') { @@ -470,38 +472,47 @@ function normalizeFilterOptions( label: (typeof label === 'string' && label) || isMap ? label : String(value), }); } else { - shorthand.push(String(o)); - normalized.push({ value: String(o), label: String(o) }); + // The retired shorthand arm (objectui#4356): nothing is pushed for this + // member. It is recorded for the warning and yields no option. + dropped.push(o); } } - if (shorthand.length > 0) warnShorthandOptions(filterName, shorthand); + if (dropped.length > 0) warnDroppedShorthandOptions(filterName, dropped); 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. + * Say the dropped members out loud — once per offending filter per session, + * naming the filter, the members that yielded no option, and the rewrite. * - * Collected per FILTER rather than per option: a filter declaring + * Collected per FILTER rather than per member: 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. + * dropped members — the object members rendered, and re-reporting them is noise. + * + * Dev-mode only and memoised, exactly as the Phase 1 deprecation warning was: + * `resolveDashboardFilterDefs` runs on every dashboard render, and a warning + * that floods a console is a warning that gets muted. What changed is the + * sentence — this one reports a member that was DROPPED and promises no lift. */ -function warnShorthandOptions(filterName: string, shorthand: string[]): void { +function warnDroppedShorthandOptions(filterName: string, dropped: unknown[]): void { if (!isDev()) return; - const memo = `${filterName}:${shorthand.join(',')}`; + // A string is quoted so `"42"` and `42` stay distinguishable; anything else is + // spelled as itself — `String()` rather than `JSON.stringify()`, because a + // programmatic caller can hand this a value JSON cannot carry. + const spelled = dropped.map((v) => (typeof v === 'string' ? JSON.stringify(v) : String(v))); + const memo = `${filterName}:${spelled.join(',')}`; if (warnedShorthandOptions.has(memo)) return; warnedShorthandOptions.add(memo); - const canonical = shorthand.map((v) => `{ value: ${JSON.stringify(v)}, label: ${JSON.stringify(v)} }`).join(', '); + const canonical = dropped + .map((v) => `{ value: ${JSON.stringify(String(v))}, label: ${JSON.stringify(String(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.`, + `filter "${filterName}": \`options\` members ${spelled.join(', ')} are not \`{ value, label }\` ` + + `objects and were DROPPED — the bare-string shorthand is no longer lifted (objectui#4356), so the ` + + `filter renders without them. Rewrite the stored dashboard to @objectstack/spec's pair form: ` + + `[${canonical}].`, ); } @@ -554,7 +565,7 @@ export function resolveDashboardFilterDefs( ...(typeof f.object === 'string' && f.object ? { object: f.object } : {}), label: f.label, type, - // `name` is the identifying context the deprecation warning needs, and + // `name` is the identifying context the dropped-member 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. diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 1e2a23be83..235664bf57 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1289,18 +1289,17 @@ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ * The spec declares the key, so under that card's ruling (5617465269, * principle 1) both faces follow the spec, both ways: the declaration already * bound `GlobalFilter` by reference (objectui#4032), and this validator now does - * too. The runtime is untouched: `@object-ui/core`'s `normalizeFilterOptions` - * still LIFTS a stored bare-string option on read, with its deprecation - * warning, on the objectstack#7917 option-② window (the spec stays strict; the - * renderer's lift retires behind a survey), and the filter bar still falls back - * to `valueField` when `labelField` is absent. This is the objectui#4165 split - * applied to `options`: the SCHEMA refuses the legacy spelling, the READ PATH - * lifts it. - * - * The `{ preset }` `defaultValue` object form was the objectui#4165 instance of - * the same shape: refused by the schema, lifted on read by - * `liftLegacyGlobalFilterDefault` (`../dashboard-filter-alias.ts`, which carries - * the retirement window). + * too. The runtime followed on the objectstack#7917 option-② schedule + * (objectui#4356): `@object-ui/core`'s `normalizeFilterOptions` no longer lifts + * a stored bare-string option — the member yields no option, and a dev-mode + * warning names it — so the SCHEMA and the READ PATH now refuse the same + * spelling. The filter bar still falls back to `valueField` when `labelField` + * is absent. + * + * The `{ preset }` `defaultValue` object form is the objectui#4165 instance of + * the split `options` has now left behind — refused by the schema, still lifted + * on read by `liftLegacyGlobalFilterDefault` (`../dashboard-filter-alias.ts`, + * which carries its own retirement window). * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`; pinned by * `__tests__/dashboard-header-global-filters-spec-7759.test.ts`. From 98623a24db5aa8b1b6837a5373590478e538d3ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:29:22 +0000 Subject: [PATCH 2/4] docs(dashboard-filters): stop describing the retired options lift in the README and three test docblocks (objectui#4356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework round 1 after contract review 5866198610 on bc4ea976e — prose only, no assertion, type, changeset level or Clause-② moves: - packages/plugin-dashboard/README.md (ships in `files`): the globalFilters example's options comment no longer says a stored bare string "is still lifted at runtime, and warns"; it says not accepted, no longer lifted, a bare member yields no option, a dev-mode warning names it, rewrite X as { value: X, label: X }. - packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts, packages/types/src/__tests__/report-chart-query-spec-parity.test.ts (its header docblock only) and packages/plugin-dashboard/src/__tests__/ DashboardRenderer.filters.test.tsx: docblocks that stated the lift now state the retirement. - packages/core/src/utils/__tests__/dashboard-filters.test.ts: the stale Phase 1 "DEPRECATED and says so" header above the RETIRED one is removed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm --- .../src/utils/__tests__/dashboard-filters.test.ts | 3 --- packages/plugin-dashboard/README.md | 6 ++++-- .../src/__tests__/DashboardRenderer.filters.test.tsx | 10 +++++----- .../dashboard-global-filters-spec-binding.test.ts | 11 ++++++----- .../__tests__/report-chart-query-spec-parity.test.ts | 9 +++++---- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index 7ebea4f1b7..bb8f1bd5af 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -626,9 +626,6 @@ describe('[#4165] legacy `{ preset }` declaration — ADR-0089 alias lift', () = }); }); -// --------------------------------------------------------------------------- -// #4356 — the bare-string `options` shorthand is DEPRECATED and says so. -// // --------------------------------------------------------------------------- // #4356 — the bare-string `options` shorthand is RETIRED: dropped, not lifted. // diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index e79d724e95..f23fd1bf21 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -388,8 +388,10 @@ into each bound widget's inline query (`AND`-combined with the widget's own "type": "select", // text | select | date | number | lookup // Canonical @objectstack/spec pair form — the only form the platform // accepts at publish, and the only form `@object-ui/types` validates - // (objectui#7759). The bare-string shorthand (["EMEA", …]) is - // deprecated: a STORED one is still lifted at runtime, and warns (objectui#4356). + // (objectui#7759). The bare-string shorthand (["EMEA", …]) is NOT + // accepted: the runtime no longer lifts it (objectui#4356) — a bare member + // yields no option, and a dev-mode warning names it. Rewrite each X as + // { "value": X, "label": X }. "options": [ { "value": "EMEA", "label": "EMEA" }, { "value": "APAC", "label": "APAC" }, diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx index e9441cdd8c..7e448c71ec 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx @@ -110,11 +110,11 @@ describe('DashboardRenderer dashboard-level filters', () => { type: 'dashboard', globalFilters: [ // Options in @objectstack/spec's `{ value, label }` pair form. The - // bare-string shorthand these used to spell is a UI-side authoring - // convenience that `GlobalFilterSchema` does not declare; its own - // coverage is `packages/core/src/utils/__tests__/dashboard-filters.test.ts`, - // which pins `normalizeFilterOptions` lifting it. Nothing here reads the - // list — the broadcast under test comes from `defaultValue`. + // bare-string shorthand these used to spell is refused by + // `GlobalFilterSchema` and, since objectui#4356, no longer lifted by + // `normalizeFilterOptions` either — a bare member yields no option; its + // own coverage is `packages/core/src/utils/__tests__/dashboard-filters.test.ts`. + // Nothing here reads the list — the broadcast under test comes from `defaultValue`. { name: 'region', field: 'region', diff --git a/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts b/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts index 63b02d2381..bd72c092f9 100644 --- a/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts +++ b/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts @@ -84,11 +84,12 @@ describe('DashboardComponentSchema.globalFilters — bound to the spec, not rest // Binding fixes both directions at once, which is the point of binding. type _ShorthandGone = Assert< Equal< Accepts< 'options', string[] >, false > >; - // NOT a runtime removal. `normalizeFilterOptions` in `@object-ui/core` - // still lifts a bare string for STORED documents — narrowing the authoring - // type and dropping tolerance for already-persisted metadata are different - // changes, and only the first is in this card's scope. The runtime half is - // filed separately. + // This card was the TYPE half only. The runtime half followed on its own + // card (objectui#4356, the objectstack#7917 option-② schedule): + // `normalizeFilterOptions` in `@object-ui/core` no longer lifts a bare + // string for STORED documents either — the member yields no option and a + // dev-mode warning names it. Narrowing the authoring type and retiring the + // runtime tolerance were different changes, and landed apart. expect(true).toBe(true); }); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index 1cbfdf329b..23791eab5c 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -174,10 +174,11 @@ describe('GlobalFilterSchema derives from the spec', () => { * by the spec on both faces. So the local schema IS the spec's now, and what is * pinned is that verdict, on the same literals, from both schemas. * - * ⚠️ Retired at the SCHEMA only. `normalizeFilterOptions` (`@object-ui/core`'s - * `dashboard-filters.ts`) still lifts a stored bare-string option on read, with - * its deprecation warning, on the objectstack#7917 window — the objectui#4165 - * split: the schema refuses, the read path lifts. + * ⚠️ Retired at the SCHEMA first, and since objectui#4356 at the READ PATH too + * (the objectstack#7917 option-② schedule): `normalizeFilterOptions` + * (`@object-ui/core`'s `dashboard-filters.ts`) no longer lifts a stored + * bare-string option — the member yields no option and a dev-mode warning + * names it — so both faces now refuse the same spelling. */ describe('GlobalFilterSchema: the local divergences are retired (objectui#7759)', () => { const both = (doc: unknown) => [GlobalFilterSchema.safeParse(doc).success, SpecGlobalFilterSchema.safeParse(doc).success]; From e0647486b538a75af429ce2ff16d33065c5b9f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:42:49 +0000 Subject: [PATCH 3/4] docs(plugin-dashboard): the i18nLabel test comment stops describing the retired mixed-array lift (objectui#4356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch round 2 (W5, claim addendum 5866480991), comment only: the '(e) leaves plain-string labels and options exactly as authored' case said the bare-string shorthand "is deprecated and now warns" and that "the mixed-array lift has its own pin" named `names ONLY the bare members of a MIXED array`, asserting a lifted `{ value: 'lost', label: 'lost' }`. The shorthand is retired — a bare member yields no option — and the pin is now `keeps ONLY the object members of a MIXED array, and names only the dropped ones`. No assertion moves. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm --- .../__tests__/DashboardFilterBar.i18nLabel.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx index b683ba9a03..26d73d45a1 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx @@ -164,13 +164,13 @@ describe('DashboardFilterBar — inline per-locale filter labels (#4032 / #4163) label: 'Stage', // 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 + // also exercise a MIXED array; that shorthand is retired + // (objectui#4356) — a bare member yields no option — and the + // mixed-array DROP 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. + // (`keeps ONLY the object members of a MIXED array, and names only + // the dropped ones`). 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' }], }, ], From a890654027a69911412efbcb146e326da8476256 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:03:56 +0000 Subject: [PATCH 4/4] docs(changeset): date-note the pending 7759 entry whose runtime sentence this PR made false (objectui#4356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W4 (claim addendum 5866346457), authorised by the maintainer's 2026-09-29 ruling 「Allow the appended note (Recommended)」 (card comment 5883360525): a dated note in the PR #10891 form is appended to .changeset/7759-dashboard-header-global-filters-spec.md, whose pending paragraph "The runtime is unchanged too: @object-ui/core still converts a STORED bare-string option into a pair when it reads the document, and logs a deprecation warning" would publish in the same release as this PR's "no longer lifted" entry. Append only: frontmatter byte-identical, 16 insertions, 0 deletions, no existing line edited. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm --- .../7759-dashboard-header-global-filters-spec.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.changeset/7759-dashboard-header-global-filters-spec.md b/.changeset/7759-dashboard-header-global-filters-spec.md index 847bf8e1fa..d4ed0047b5 100644 --- a/.changeset/7759-dashboard-header-global-filters-spec.md +++ b/.changeset/7759-dashboard-header-global-filters-spec.md @@ -50,3 +50,19 @@ conversion keeps its own retirement schedule (objectstack#7917). `DashboardRenderer` now shows the label in the active language, both as the button text and as the fallback for the `dashboards.NAME.actions.KEY.label` bundle lookup. + +⚠️ **Dated note, 2026-09-29 — the runtime bare-string lift has since retired — +objectui#4356.** Later in this same release `@object-ui/core`'s +`normalizeFilterOptions` stopped converting a STORED bare-string option into a +pair: a `globalFilters[].options` member that is not a `{ value, label }` object +now yields no option, a shorthand-only filter resolves with no `options`, and a +mixed array keeps only its object members; in development a once-per-filter +`console.warn` names the filter and the dropped members. The objectstack#7917 +retirement window is closed (maintainer, 2026-09-02, verbatim 「objectstack#7917 +不考虑现有数据」). So "The runtime is unchanged too: `@object-ui/core` still +converts a STORED bare-string option into a pair when it reads the document, and +logs a deprecation warning" and "That conversion keeps its own retirement +schedule (objectstack#7917)" above no longer hold; the validator's refusal of +`options: ['EMEA']` described above is unchanged and now matches the read path. +`.changeset/retire-options-shorthand-lift-4356.md` (PR objectui#10930) states +what ships; the text above is kept as the reading of this change.