Skip to content

Commit a51fa0c

Browse files
feat(core)!: retire the bare-string globalFilters[].options lift (objectui#4356) (#10930)
Fixes #4356 Clause-②: yes — published runtime behaviour is removed (the bare-string lift, and the exported `resetDashboardFilterWarnings` if it retires with its memo), per the execution parameters in `5511028727`. The claim's "if" resolved to **stays**: the memo survives (it now dedupes the dropped-member warning, see "The audible-skip question"), so `resetDashboardFilterWarnings` keeps its job and its export; the changeset names no removed export. Final head `a890654027a69911412efbcb146e326da8476256`. Commits on this branch since the branch point `733fd5ac6`: `bc4ea976e` (the retirement), `98623a24d` (rework round 1: W1–W3), `e0647486b` (patch round 2: W5), the two merges of `origin/main` (`1daf7edf7` at `29c76e573`, `a1058f90e` at `2eaf5be27`), and `a89065402` (W4, the dated note). Implemented in session `session_012UwY3ahMixEFkfTUxMVkYm`. ## What changed Phase 2 of objectui#4356 — the runtime bare-string `globalFilters[].options` lift retires. - `packages/core/src/utils/dashboard-filters.ts` — in `normalizeFilterOptions`, the non-object arm no longer pushes `{ value: String(o), label: String(o) }`. A member that is not an object (string, number, boolean) yields NO option: a shorthand-only filter resolves with no `options`, a mixed array keeps only its object members. The nullish / value-less skips are unchanged. The Phase 1 deprecation warning (`warnShorthandOptions`, whose text said "Still lifted here") is deleted; a dev-mode, once-per-filter `warnDroppedShorthandOptions` names the filter, the dropped members and the rewrite, and promises no lift. The `DashboardFilterDef.options` JSDoc, the `normalizeFilterOptions` docblock, the memo docblock and the `resetDashboardFilterWarnings` docblock stop describing a lift. No lift, alias, fallback or migration is added anywhere. - `packages/core/src/utils/__tests__/dashboard-filters.test.ts` — every lift-pinning test is flipped to pin the new contract (red-first, quoted under Tests): `normalizes options: spec {value,label} objects AND bare-string shorthand` became `normalizes spec {value,label} objects, and DROPS a bare-string member rather than lifting it`; the `[#4356]` describe is `retired: dropped, not lifted` — a shorthand-only filter yields no `options`; a mixed array keeps only its object members; a number and a boolean are dropped too; warn ONCE across renders while dropping on every render; a different filter warns separately; canonical `{ value, label }` options and an `I18nLabel` map are untouched and silent; `resetDashboardFilterWarnings` re-arms the memo. The stale Phase 1 header above the block is removed (W3). - `content/docs/guide/dashboard-filters.md` — the "Deprecated: … still lifted by the runtime" callout became false and now reads "Not accepted": refused at publish, no longer lifted, a bare-string member yields no option, rewrite `"X"` as `{ "value": "X", "label": "X" }`. No fenced block changed. - `packages/types/src/zod/complex.zod.ts` — the JSDoc of the exported `GlobalFilterSchema` said the runtime "still LIFTS a stored bare-string option on read"; that sentence ships in `@object-ui/types`' emitted declarations and is corrected in place. Outside the claim's original surface; accepted by the seat's addendum, see "Deviations". - `packages/plugin-dashboard/README.md` (W1, ships in that package's `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, the runtime no longer lifts it (objectui#4356), a bare member yields no option, a dev-mode warning names it, rewrite each X as `{ "value": X, "label": X }`. - Three test docblocks state the retirement instead of the lift (W2): `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 — nothing near objectui#10916's `JoinedReportBlock` pin, which PR #10934 rewrote and this branch has merged), `packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx`. A fourth (W5), `packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx`, no longer says the shorthand "is deprecated and now warns" nor names the renamed lift pin; it names the current DROP pin. No assertion moved in any of the four. - `.changeset/retire-options-shorthand-lift-4356.md` — `@object-ui/core: minor`, opening with the ruled wording: "bare-string `globalFilters[].options` is no longer lifted; use `{ value, label }` objects, the spec's form". Never `major` (AGENTS.md §9 version policy: the break is stated in the body, the bump stays `minor`); `check-changeset-no-major` exit 0. - `.changeset/7759-dashboard-header-global-filters-spec.md` (W4) — a dated note appended, nothing else touched. That pending entry says "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. That conversion keeps its own retirement schedule (objectstack#7917)." — a sentence this PR makes false in the same release. The note (PR #10891 form, dated 2026-09-29) says which sentences no longer hold and what ships. Authorised by the maintainer's ruling of 2026-09-29, option 「Allow the appended note (Recommended)」, recorded on the card as `5883360525`; the option reads, verbatim: "You allow developers to append dated correction notes to pending .changeset files (appended only, no existing line or frontmatter edited). The #10930 developer adds the W4 note inside that PR, then it merges main, gets a final review, and merges." Measured: 16 insertions, 0 deletions; frontmatter md5 `ac1bfd0472c0d0b981d923c32d435f18` before and after; whole-file md5 `94edd5855709afe137f9184c630f298c` → `5c8f0508bed5ed6f5b76254bb74d622d`; 0 angle-bracket characters; `diff` against `origin/main` shows only the appended block. Untouched on purpose: `examples/schema-catalog/test/plugin-dashboard-global-filters-spec.test.ts` (the `GlobalFilterSchema` catalog guardrail from PR #4601 — the positive control; its docblock narrates history and spec facts only, and stays true); the assertions of the two `packages/types` tests that pin the SPEC's refusal of `options: ['EMEA']` (rejection pins, not lift pins); `@objectstack/spec`; `content/docs/releases/**`; `skills/**` (no file there becomes false — measured below). ## Rulings executed - objectstack-ai/objectstack#7917, maintainer 2026-08-12, verbatim 「7917 ②」 — the spec stays strict; the runtime bare-string lift retires. - Maintainer, 2026-09-02, verbatim 「objectstack#7917 不考虑现有数据」 (director release `5511028727` on objectui#4356) — Phase 2 on release cadence alone; no live-tenant survey, no migration entry. - Maintainer direct dispatch to the `domain:spec @ objectui` seat, verbatim 「这个你也接管继续 #4356」. - Maintainer, 2026-09-29, 「Allow the appended note (Recommended)」 (card comment `5883360525`) — authorises the W4 append in exactly the form landed here. ## The audible-skip question — governing text found The dispatch asked whether any governing text requires the post-removal skip to be audible. What was read, verbatim: - objectstack `AGENTS.md`, "Degradation log levels — `warn` vs `error`": "**Functional degradation → `warn` / `info`.** A screen is missing, a trigger is not armed, a capability is not enabled … The system is *visibly* smaller than it should be, and the next person to use the missing thing finds out." and "**prefer failing to falling back**, and when you must fall back, say what was lost." — A `select` filter whose members were all bare strings now renders with an empty option list (`DashboardFilterBar`: `const authored = def.options?.length ? def.options : (dynamicOptions ?? [])`): visibly smaller, functional, `warn`-level — and "say what was lost" is the operative sentence. - objectstack `docs/adr/0078-no-silently-inert-metadata.md`, §4 "Loud-not-silent at the runtime seam": "Where the runtime *skips* an inert instance … it emits a **dev-mode diagnostic** at registration rather than swallowing it silently. This is the only layer that is **authoring-tool-agnostic**". The ADR's completeness gate (§1) is about Zod-VALID instances and a bare string is Zod-invalid, but §4 is exactly the case here: a STORED dashboard is the one document no author-time gate re-reads. - objectui `AGENTS.md`: no clause on audibility; #0.1 (contract-first, no lenient fallback) is honoured — a diagnostic is not a fallback, and nothing is lifted. - The module's own convention, same file: `buildFilterCondition` — "anything else → skipped with a console warning, never silently downgraded"; `buildWidgetScopedFilter` skips an unknown default field "with a console warning instead of emitting a query". Conclusion: yes, at `warn`, dev-mode, deduped — the smallest audible path consistent with the ruling. Implemented as `warnDroppedShorthandOptions`: same `isDev()` gate and memo key as Phase 1 (no per-render flood), new sentence (DROPPED, "no longer lifted", the rewrite), no lift. Consequence: the memo and therefore `resetDashboardFilterWarnings` stay. The contract review (`5866198610`) judged this RIGHT; dev-only rather than always-on is the same review's answer for this PR. ## Measurements against the dispatch's mechanism assumptions 1. Lift live on `origin/main` `733fd5ac6` — confirmed: the `else` arm pushed `{ value: String(o), label: String(o) }` and called `warnShorthandOptions` for EVERY non-nullish primitive. Pinned now in `drops EVERY non-object member — a number and a boolean, not only a string`. 2. `resetDashboardFilterWarnings` — `git grep` repo-wide: importers are `dashboard-filters.test.ts` only (plus a `packages/core/CHANGELOG.md` paragraph); it clears only `warnedShorthandOptions`. The inference "with the memo gone it has no job" is falsified by (4): the memo does not go. It stays, with a docblock saying why. 3. Fixture recount, repo-wide over every file containing `globalFilters` (tests included; positive control: the same matcher found the callout it was meant to find): lift-pinning bare-string members existed only in `dashboard-filters.test.ts` — six members across five tests (the dispatch's list lacked the bare `'lost'` of the mixed-array test; the director's "three" and the dispatch's "five" were both stale). All six retired or flipped. The two `packages/types` occurrences pin the spec's REFUSAL and keep their assertions. 4. Skip audibility — see the section above. 5. Teaching sites — swept at the final head with `git grep -n -i -E 'lift' -- ':!**/CHANGELOG.md' ':!content/docs/releases/**' ':!pnpm-lock.yaml' | grep -i -E 'shorthand|bare[- ]string|4356'` over the whole tracked tree (a superset of every package's `files[]`, `content/docs`, `skills/`, `examples/`): 20 hits on `bc4ea976e` with `packages/plugin-dashboard/README.md`'s "still lifted at runtime" line as the found positive control; 18 on `a89065402` — every hit either states the retirement (this PR's changeset, source, docs and test text) or is the unrelated `data`-array shorthand in `plugin-grid` / `plugin-map`. The first-round sweep's `option|filter` post-filter is what hid the README line (W1) and the `i18nLabel` comment (W5); the rework sweep has no post-filter. When the sweep runs on a ref, the ref prefix is stripped before the second grep — this branch's name contains both "shorthand" and "lift". Three other sites author `options: ['…']` on `select` FIELDS (form / object field schemas: `content/docs/guide/expressions.md`, `content/docs/guide/schema-playground.md`, `skills/objectui/guides/project-setup.md`) — a different contract, out of this card, untouched; the governed `skills/**` file does not become false. 6. Release — `packages/core/package.json` is `17.6.0`; `.changeset/config.json` has `@object-ui/core` in the `fixed` group; a `minor` changeset makes the next release ≥ 17.7.0; release PR #5400 is open (not touched). 7. Pending changesets — swept on the merged tree (2326 pending entries) with `git grep -n -i -E 'normalizeFilterOptions|bare[- ]string|shorthand|lift' -- '.changeset/*.md'` filtered to option / `globalFilters` / 4356 / 7917 context: exactly ONE pending sentence this PR makes false, the `7759` paragraph quoted above (W4). Its earlier sentence "the bare-string option shorthand (`options: ['EMEA']`) is refused with `invalid_type`" stays true. Every other hit is a different contract (export-options bare array, object-map / gantt `data` arrays, kanban `columns`, picklist / field `options`, widget shorthand). The nine entries the `changeset-claim-re-read` comment `5865883972` lists were re-read: none says anything about the options lift; all still true. ## Merges of `main` - `1daf7edf7` — `origin/main` `29c76e573` (PR #10934, objectui#10916). No conflicts; `report-chart-query-spec-parity.test.ts` auto-merged (disjoint hunks); both sides verified — my header-docblock correction present, PR #10934's pin text byte-for-byte as `main` has it. - `a1058f90e` — `origin/main` `2eaf5be27` (70 commits, among them PR #10914's citation edits in `dashboard-filters.ts` and three `complex.zod.ts` commits). No conflicts; after the merge the files differing from `origin/main` are exactly this PR's ten, and `git diff origin/main` on `complex.zod.ts` and on the parity test is exactly this PR's docblock hunks. No rebase, no amend, no force-push at any point; every push was a plain `git push`. ## Tests Red-first (at `bc4ea976e`, the new pins against the UNTOUCHED source, `pnpm exec vitest run packages/core/src/utils/__tests__/dashboard-filters.test.ts`, exit 1): ```text × normalizes spec {value,label} objects, and DROPS a bare-string member rather than lifting it (objectui#4356) × DROPS a bare string: a shorthand-only filter resolves with no `options`, and says so × keeps ONLY the object members of a MIXED array, and names only the dropped ones × drops EVERY non-object member — a number and a boolean, not only a string × warns ONCE per offending filter across repeated renders, and drops on every one of them AssertionError: expected [ …(2) ] to be undefined AssertionError: expected [ Array(2) ] to deeply equal [ { value: 'won', label: 'Won' } ] AssertionError: expected [ { value: '42', label: '42' }, …(2) ] to deeply equal [ { value: 'gold', label: 'Gold' } ] Test Files 1 failed (1) Tests 5 failed | 44 passed (49) ``` The three that stayed green on the old code are the ones the block labels as non-discriminating (different-filter memo, canonical silence, memo reset). Green after the source change, same command: `Tests 49 passed (49)`. Final head `a89065402`, one `pnpm exec vitest run` from the repo root under the shared verify lock over `packages/core/` plus the two `packages/types` global-filter tests and the two `plugin-dashboard` dashboard-filter tests this PR touched: `Test Files 188 passed (188)` · `Tests 3725 passed | 27 skipped (3752)`, exit 0. The changeset script tests (`check-changeset-{presence,no-major,fixed,overwrite,claims}`, `check-pending-changeset-literals`, `changeset-polarity-census`): `Test Files 7 passed (7)` · `Tests 292 passed (292)`, exit 0. Consumer sweep at `bc4ea976e` (`resolveDashboardFilterDefs` consumers re-derived by `git grep`: `core`, `types`, `plugin-dashboard`, `plugin-designer`, `app-shell`, `examples/schema-catalog`), exit 0: `packages/core` 182 files / 3643 tests; `packages/plugin-dashboard` 147 / 1346 (6 pre-existing skips); `packages/types` 3 / 58; `packages/plugin-designer` (`DashboardDesignPage.presetAlias`) 1 / 4; `packages/app-shell` (`DashboardWidgetInspector.test.tsx`) 1 / 19; `examples/schema-catalog` (the guardrail) 1 / 9 — totals `335 passed (335)` · `5073 passed | 6 skipped (5079)`. No consumer fixture asserted the old lift. The code has not changed since that head (every later commit is prose or a merge), and the final-head run above re-covers `core`, `types` and the touched `plugin-dashboard` tests. ## Gates on the final head `a89065402` (hand-derived from root `package.json` and `.github/workflows/`; exit codes captured before any pipe) | gate | exit | note | |---|---|---| | `pnpm --filter '@object-ui/core^...' build` then `pnpm --filter @object-ui/core build` | 0 / 0 | dependency closure first, so the type-checks read rebuilt declarations | | `pnpm --filter @object-ui/core type-check` · `pnpm --filter @object-ui/types type-check` | 0 / 0 | | | `pnpm --filter @object-ui/core lint` · `pnpm --filter @object-ui/types lint` | 0 / 0 | 0 errors; the warnings are pre-existing `no-explicit-any` — on the touched files the `as any` counts equal the base (2 / 10 / 0), unchanged since `bc4ea976e` | | vitest `packages/core/` + the four touched dashboard-filter tests (under the lock) | 0 | 188 files, 3725 passed, 27 skipped | | vitest changeset script tests ×7 | 0 | 292 passed | | `node scripts/check-changeset-presence.mjs` | 0 | 11 files changed vs merge-base `2eaf5be27`, 7 published source files of 3 released packages, 1 changeset added | | `node scripts/check-changeset-no-major.mjs` · `check-changeset-fixed.mjs` · `check-changeset-claims.mjs` · `check-changeset-overwrite.mjs` | 0 ×4 | claims and overwrite are report-only; the W4 append is overwrite's documented "correcting a declaration on purpose" case | | `pnpm check:pending-changeset-literals` | 0 | "No test source names a pending changeset" | | `node scripts/check-control-bytes.mjs` | 0 | 9331 tracked text files | | `node scripts/check-new-cross-file-line-citations.mjs` | 0 | 0 new citations (the W4 note cites by quoted sentence) | | `node scripts/check-governed-queue-guard.mjs --test` (every touched path) | 0 | NOT GOVERNED | | `pnpm check:doc-fences` · `check:doc-types` · `check:doc-example-ids` · `check:spec-symbols` · `node scripts/check-doc-links.mjs` · `check:test-path-roots` · `check:vi-mock-specifiers` · `check:phantom-deps` | 0 ×8 | measured at `bc4ea976e` / `98623a24d`; the files they read have not changed since | | `pnpm check:doc-snippets` · `pnpm check:readme-exports` | 2 / 1 | NOT MEASURED locally — both `PRECONDITION NOT MET` (unbuilt packages); CI's Doc Snippet Type Check and README Exports are the readings. The docs hunk changes no fenced block; the README hunk is a JSON comment with no import binding | Not run locally, CI-owned: repo-wide `pnpm lint`, `pnpm test`, `pnpm type-check` (the public type face is unchanged — no export added or removed, no signature changed; emitted declarations differ in JSDoc text only), `pnpm check`, the i18n gates, `plugin-dashboard`'s own type-check (comment-only hunks in two test files). ## Deviations - `packages/types/src/zod/complex.zod.ts` was outside claim `5865368364`'s original file surface; edited under the bounded in-place-fix exemption (same defect class; mechanical; no other open PR on the file; inside core's type-checked closure) and accepted by the seat's addendum `5865980488`. The later addenda `5866223135`, `5866346457`, `5866480991` added the W1–W5 files and the `7759` changeset. - The first-round teaching sweep under-reported (W1, W5) because of its `option|filter` post-filter; corrected above. - The W4 append was first refused by the auto-mode classifier ("Modify Shared Resources", record `5866443287`), held and not re-routed; it landed only after the maintainer's 2026-09-29 ruling. ## Acceptance notes - `warnDroppedShorthandOptions` is dev-only, exactly as the Phase 1 warning was; the module's other skip warnings are not dev-gated. Always-on is a one-line product decision the review left to the maintainer; not this PR's. - Field-level `select` schemas teaching `options: ['…']` are a different contract and were not judged here — observation only, not filed. - `packages/core/CHANGELOG.md` and `packages/plugin-dashboard/CHANGELOG.md` narrate the lift as history; published record, not re-addressed (the CHANGELOG clause under AGENTS.md #11: never re-address a paragraph in a published CHANGELOG). - PR #10914 (objectui#10803 batch 6) has landed on `main` and is merged into this branch; no other open PR touches this PR's files. --- _Generated by [Claude Code](https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49f7672 commit a51fa0c

11 files changed

Lines changed: 237 additions & 171 deletions

‎.changeset/7759-dashboard-header-global-filters-spec.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,19 @@ conversion keeps its own retirement schedule (objectstack#7917).
5050
`DashboardRenderer` now shows the label in the active language, both as the
5151
button text and as the fallback for the
5252
`dashboards.NAME.actions.KEY.label` bundle lookup.
53+
54+
⚠️ **Dated note, 2026-09-29 — the runtime bare-string lift has since retired —
55+
objectui#4356.** Later in this same release `@object-ui/core`'s
56+
`normalizeFilterOptions` stopped converting a STORED bare-string option into a
57+
pair: a `globalFilters[].options` member that is not a `{ value, label }` object
58+
now yields no option, a shorthand-only filter resolves with no `options`, and a
59+
mixed array keeps only its object members; in development a once-per-filter
60+
`console.warn` names the filter and the dropped members. The objectstack#7917
61+
retirement window is closed (maintainer, 2026-09-02, verbatim 「objectstack#7917
62+
不考虑现有数据」). So "The runtime is unchanged too: `@object-ui/core` still
63+
converts a STORED bare-string option into a pair when it reads the document, and
64+
logs a deprecation warning" and "That conversion keeps its own retirement
65+
schedule (objectstack#7917)" above no longer hold; the validator's refusal of
66+
`options: ['EMEA']` described above is unchanged and now matches the read path.
67+
`.changeset/retire-options-shorthand-lift-4356.md` (PR objectui#10930) states
68+
what ships; the text above is kept as the reading of this change.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/core': minor
3+
---
4+
5+
feat(core)!: bare-string `globalFilters[].options` is no longer lifted; use `{ value, label }` objects, the spec's form (objectui#4356).
6+
7+
**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.
8+
9+
**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.
10+
11+
**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" }`.
12+
13+
`resetDashboardFilterWarnings()` stays exported: the warn-once memo it clears now guards the dropped-member warning instead of the deprecation warning.

‎content/docs/guide/dashboard-filters.md‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,14 +180,13 @@ published, and anything else is refused there. objectui's own validator
180180
(`@object-ui/types`, which `objectui validate` runs) uses the same spec schema, so
181181
it refuses the same documents before they reach the platform.
182182

183-
> **Deprecated: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is
184-
> still lifted by the runtime to `{ "value": "EMEA", "label": "EMEA" }` pairs so
185-
> that already-stored dashboards keep rendering, but it now logs a deprecation
186-
> warning naming the filter, and it is scheduled for removal
187-
> ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)).
188-
> Write the object form. The lift is mechanically lossless, so migrating a
189-
> stored dashboard is a direct rewrite of each string `X` to
190-
> `{ "value": "X", "label": "X" }`.
183+
> **Not accepted: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is
184+
> refused by `GlobalFilterSchema` at publish, and the runtime no longer lifts it
185+
> either ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)):
186+
> a bare-string member yields no option, so a stored dashboard authored this way
187+
> renders that filter with an empty option list, and in development a single
188+
> `console.warn` names the filter and the dropped members. Rewrite each string
189+
> `X` as `{ "value": "X", "label": "X" }`.
191190
192191
Options can also be fetched from an object at runtime:
193192

‎packages/core/src/utils/__tests__/dashboard-filters.test.ts‎

Lines changed: 92 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ const dateDef: DashboardFilterDef = {
3838
};
3939

4040
describe('resolveDashboardFilterDefs', () => {
41-
it('normalizes options: spec {value,label} objects AND bare-string shorthand → {value,label} pairs', () => {
42-
// The shorthand arm now also emits the #4356 deprecation warning, so this
43-
// case captures `console.warn` rather than letting it reach the suite's
44-
// output — the warning must be audible to AUTHORS, not to our own test log.
45-
// Its own pins are in the `[#4356]` block at the foot of this file.
41+
it('normalizes spec {value,label} objects, and DROPS a bare-string member rather than lifting it (objectui#4356)', () => {
42+
// The dropped member is reported once by a dev-mode `console.warn`, so this
43+
// case captures it rather than letting it reach the suite's output — the
44+
// warning must be audible to AUTHORS, not to our own test log. Its own pins
45+
// are in the `[#4356]` block at the foot of this file.
4646
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
4747
resetDashboardFilterWarnings();
4848
let defs;
@@ -52,7 +52,7 @@ describe('resolveDashboardFilterDefs', () => {
5252
// @objectstack/spec object form — rendering this un-normalized as a
5353
// React child crashed the Revenue Pulse dashboard (caught in dogfood).
5454
{ name: 'region', field: 'region', type: 'select', options: [{ value: 'amer', label: 'AMER' }, { value: 'emea', label: 'EMEA' }] },
55-
// objectui bare-string shorthand — DEPRECATED (#4356), still lifted.
55+
// The RETIRED bare-string shorthand (#4356) — yields no option at all.
5656
{ name: 'status', field: 'status', type: 'select', options: ['draft', 'paid'] },
5757
] as any,
5858
});
@@ -63,10 +63,7 @@ describe('resolveDashboardFilterDefs', () => {
6363
{ value: 'amer', label: 'AMER' },
6464
{ value: 'emea', label: 'EMEA' },
6565
]);
66-
expect(defs[1].options).toEqual([
67-
{ value: 'draft', label: 'draft' },
68-
{ value: 'paid', label: 'paid' },
69-
]);
66+
expect(defs[1].options).toBeUndefined();
7067
});
7168

7269
it('maps dateRange to the reserved name with a created_at field default', () => {
@@ -630,28 +627,34 @@ describe('[#4165] legacy `{ preset }` declaration — ADR-0089 alias lift', () =
630627
});
631628

632629
// ---------------------------------------------------------------------------
633-
// #4356 — the bare-string `options` shorthand is DEPRECATED and says so.
630+
// #4356 — the bare-string `options` shorthand is RETIRED: dropped, not lifted.
634631
//
635632
// Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」: the
636-
// spec stays strict and the runtime lift retires behind a deprecation window.
637-
// This block is the warn half (Phase 1). The lift itself is unchanged — the
638-
// LIFT pins live in `resolveDashboardFilterDefs` above and stay green in both
639-
// directions, which is exactly what "the lift is untouched" has to mean.
633+
// spec stays strict and the runtime lift retires. PR #4601 shipped the warn half
634+
// (Phase 1); the window closed on the 2026-09-02 ruling, verbatim
635+
// 「objectstack#7917 不考虑现有数据」, and this block pins the end state
636+
// (Phase 2): a non-object member yields no option, a shorthand-only filter
637+
// resolves with no `options`, and a mixed array keeps only its object members.
640638
//
641639
// What each pin here is for:
642-
// - the WARNING pin is the discriminating one: it goes red the moment the
643-
// warning is removed, and it is what makes the window closable (ADR-0078 —
644-
// a silent lift can never be retired, because nothing would ever show that
645-
// the last shorthand document is gone);
640+
// - the DROP pins are the discriminating ones: each went red against the
641+
// Phase 1 build (which lifted `'EMEA'` to `{ value: 'EMEA', label: 'EMEA' }`)
642+
// before the arm was removed — red-first, quoted in the landing PR;
643+
// - the WARNING pin keeps the skip audible (ADR-0078 §4 — where the runtime
644+
// skips an instance it emits a dev-mode diagnostic rather than swallowing
645+
// it): a stored dashboard is the one document no author-time gate re-reads,
646+
// and an empty select with no line in the console reads as "no data". The
647+
// warning names the filter, the dropped members and the rewrite, and it
648+
// promises NO lift — the Phase 1 sentence "Still lifted here" retired with
649+
// the arm, which the negative pin below guards;
646650
// - the ONCE pin protects the render path. `resolveDashboardFilterDefs` runs
647651
// on every dashboard render, so a warning without the memo floods the
648652
// console per frame — and a warning that floods is a warning that gets muted;
649-
// - the CANONICAL-SILENCE pin is a false-positive guard, and it is honestly
650-
// NOT a discrimination proof: it passes vacuously against a build with no
651-
// warning at all. Its value is post-change — it goes red if the warn ever
652-
// starts firing on healthy dashboards, which would be every dashboard.
653+
// - the CANONICAL-SILENCE pin is a false-positive guard, honestly NOT a
654+
// discrimination proof: it passes vacuously against a build with no warning
655+
// at all. It goes red if the warn ever fires on healthy dashboards.
653656
// ---------------------------------------------------------------------------
654-
describe('[#4356] bare-string `options` shorthand — deprecation warning', () => {
657+
describe('[#4356] bare-string `options` shorthand — retired: dropped, not lifted', () => {
655658
/** Capture warnings without letting them reach the suite's console. */
656659
const resolveQuietly = (globalFilters: unknown[]) => {
657660
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -665,48 +668,78 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () =
665668
}
666669
};
667670

671+
/** The one warning this block is about, picked by its card, not its prose. */
672+
const shorthandWarnings = (warnings: string[]) => warnings.filter((m) => m.includes('objectui#4356'));
673+
668674
beforeEach(() => {
669675
resetDashboardFilterWarnings();
670676
});
671677

672-
it('still lifts a bare string, byte-identically, AND warns', () => {
678+
it('DROPS a bare string: a shorthand-only filter resolves with no `options`, and says so', () => {
673679
const { defs, warnings } = resolveQuietly([
674680
{ name: 'region', field: 'region', type: 'select', options: ['EMEA', 'APAC'] },
675681
]);
676682

677-
// The lift is untouched — mechanically lossless, as the survey measured.
678-
expect(defs[0].options).toEqual([
679-
{ value: 'EMEA', label: 'EMEA' },
680-
{ value: 'APAC', label: 'APAC' },
683+
// Nothing is lifted — the spec's reading of the document, no dialect beside it.
684+
expect(defs).toHaveLength(1);
685+
expect(defs[0].options).toBeUndefined();
686+
687+
const w = shorthandWarnings(warnings);
688+
expect(w).toHaveLength(1);
689+
// Names the filter, the dropped members and the rewrite — a warning an
690+
// author cannot act on is noise.
691+
expect(w[0]).toContain('filter "region"');
692+
expect(w[0]).toContain('"EMEA"');
693+
expect(w[0]).toContain('"APAC"');
694+
expect(w[0]).toContain('{ value: "EMEA", label: "EMEA" }');
695+
// …and promises no lift. The Phase 1 text said "Still lifted here"; that
696+
// sentence is the one thing the ruling forbids this warning to carry.
697+
expect(w[0]).not.toContain('lifted here');
698+
});
699+
700+
it('keeps ONLY the object members of a MIXED array, and names only the dropped ones', () => {
701+
// Partial migrations happen — the survey found one in this very repo. The
702+
// object member renders; re-reporting it would send the author back to an
703+
// option they had just fixed.
704+
const { defs, warnings } = resolveQuietly([
705+
{ name: 'stage', field: 'stage', type: 'select', options: [{ value: 'won', label: 'Won' }, 'lost'] },
681706
]);
707+
expect(defs[0].options).toEqual([{ value: 'won', label: 'Won' }]);
708+
const w = shorthandWarnings(warnings);
709+
expect(w).toHaveLength(1);
710+
expect(w[0]).toContain('"lost"');
711+
expect(w[0]).not.toContain('"won"');
712+
});
682713

683-
const shorthandWarnings = warnings.filter((m) => m.includes('bare-string shorthand'));
684-
expect(shorthandWarnings).toHaveLength(1);
685-
// Names the offending filter, the offending values, and the canonical form
686-
// — a warning an author cannot act on is not a deprecation, it is noise.
687-
expect(shorthandWarnings[0]).toContain('filter "region"');
688-
expect(shorthandWarnings[0]).toContain('"EMEA"');
689-
expect(shorthandWarnings[0]).toContain('{ value: "EMEA", label: "EMEA" }');
690-
expect(shorthandWarnings[0]).toContain('objectui#4356');
714+
it('drops EVERY non-object member — a number and a boolean, not only a string', () => {
715+
// Phase 1 lifted every non-nullish primitive through `String(o)`; the
716+
// retirement removes the whole arm, not the string case of it.
717+
const { defs, warnings } = resolveQuietly([
718+
{ name: 'tier', field: 'tier', type: 'select', options: [42, true, { value: 'gold', label: 'Gold' }] },
719+
]);
720+
expect(defs[0].options).toEqual([{ value: 'gold', label: 'Gold' }]);
721+
const w = shorthandWarnings(warnings);
722+
expect(w).toHaveLength(1);
723+
expect(w[0]).toContain('42');
724+
expect(w[0]).toContain('true');
691725
});
692726

693-
it('warns ONCE per offending filter across repeated renders, not once per render', () => {
727+
it('warns ONCE per offending filter across repeated renders, and drops on every one of them', () => {
694728
// The render path calls this on every frame. Three resolves, one warning.
695729
const filters = [{ name: 'status', field: 'status', type: 'select', options: ['draft', 'paid'] }];
696730
const first = resolveQuietly(filters);
697731
const second = resolveQuietly(filters);
698732
const third = resolveQuietly(filters);
699733

700-
expect(first.warnings.filter((m) => m.includes('bare-string shorthand'))).toHaveLength(1);
701-
expect(second.warnings.filter((m) => m.includes('bare-string shorthand'))).toHaveLength(0);
702-
expect(third.warnings.filter((m) => m.includes('bare-string shorthand'))).toHaveLength(0);
734+
expect(shorthandWarnings(first.warnings)).toHaveLength(1);
735+
expect(shorthandWarnings(second.warnings)).toHaveLength(0);
736+
expect(shorthandWarnings(third.warnings)).toHaveLength(0);
703737

704-
// …and the lift keeps working on every one of them, memo or not. A dedupe
705-
// that also suppressed the BEHAVIOUR would be a silent data change.
706-
expect(third.defs[0].options).toEqual([
707-
{ value: 'draft', label: 'draft' },
708-
{ value: 'paid', label: 'paid' },
709-
]);
738+
// The memo mutes the WARNING, never the behaviour: the member is dropped on
739+
// every resolve, memo or not — a dedupe that also changed the output would
740+
// be a silent data change.
741+
expect(first.defs[0].options).toBeUndefined();
742+
expect(third.defs[0].options).toBeUndefined();
710743
});
711744

712745
it('warns separately for a DIFFERENT filter — the memo is not a global mute', () => {
@@ -716,13 +749,13 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () =
716749
{ name: 'region', field: 'region', type: 'select', options: ['EMEA'] },
717750
{ name: 'status', field: 'status', type: 'select', options: ['draft'] },
718751
]);
719-
const shorthandWarnings = warnings.filter((m) => m.includes('bare-string shorthand'));
720-
expect(shorthandWarnings).toHaveLength(2);
721-
expect(shorthandWarnings[0]).toContain('filter "region"');
722-
expect(shorthandWarnings[1]).toContain('filter "status"');
752+
const w = shorthandWarnings(warnings);
753+
expect(w).toHaveLength(2);
754+
expect(w[0]).toContain('filter "region"');
755+
expect(w[1]).toContain('filter "status"');
723756
});
724757

725-
it('says NOTHING for canonical `{ value, label }` options', () => {
758+
it('says NOTHING for canonical `{ value, label }` options, and keeps an I18nLabel map intact', () => {
726759
// False-positive guard: this would otherwise fire on every healthy
727760
// dashboard in the product.
728761
const { defs, warnings } = resolveQuietly([
@@ -733,28 +766,19 @@ describe('[#4356] bare-string `options` shorthand — deprecation warning', () =
733766
options: [{ value: 'emea', label: 'EMEA' }, { value: 'apac', label: { en: 'APAC', 'zh-CN': '亚太' } }],
734767
},
735768
]);
736-
expect(warnings.filter((m) => m.includes('bare-string shorthand'))).toEqual([]);
769+
expect(shorthandWarnings(warnings)).toEqual([]);
737770
// The I18nLabel map survives untouched (#4032 / #4163 must-not-change).
738771
expect(defs[0].options).toEqual([
739772
{ value: 'emea', label: 'EMEA' },
740773
{ value: 'apac', label: { en: 'APAC', 'zh-CN': '亚太' } },
741774
]);
742775
});
743776

744-
it('names ONLY the bare members of a MIXED array', () => {
745-
// Partial migrations happen — the survey found one in this very repo. A
746-
// warning that re-reported the already-canonical members would send the
747-
// author back to options they had just fixed.
748-
const { defs, warnings } = resolveQuietly([
749-
{ name: 'stage', field: 'stage', type: 'select', options: [{ value: 'won', label: 'Won' }, 'lost'] },
750-
]);
751-
const shorthandWarnings = warnings.filter((m) => m.includes('bare-string shorthand'));
752-
expect(shorthandWarnings).toHaveLength(1);
753-
expect(shorthandWarnings[0]).toContain('"lost"');
754-
expect(shorthandWarnings[0]).not.toContain('"won"');
755-
expect(defs[0].options).toEqual([
756-
{ value: 'won', label: 'Won' },
757-
{ value: 'lost', label: 'lost' },
758-
]);
777+
it('`resetDashboardFilterWarnings` re-arms the memo — the export keeps its job', () => {
778+
const filters = [{ name: 'region', field: 'region', type: 'select', options: ['EMEA'] }];
779+
expect(shorthandWarnings(resolveQuietly(filters).warnings)).toHaveLength(1);
780+
expect(shorthandWarnings(resolveQuietly(filters).warnings)).toHaveLength(0);
781+
resetDashboardFilterWarnings();
782+
expect(shorthandWarnings(resolveQuietly(filters).warnings)).toHaveLength(1);
759783
});
760784
});

0 commit comments

Comments
 (0)