fix(components,i18n): between needs both bounds; label six operators in every locale pack - #4962
fix(components,i18n): between needs both bounds; label six operators in every locale pack#4962os-project-manager wants to merge 4 commits into
Conversation
…in every locale pack Console list-filter panel, two defects (objectstack#8815). A `between` row with one bound typed was `["2024-01-01", ""]`, and both write paths asked "is this row filled in?" with one shape-blind predicate (`null` / `''` / empty array). An array of length 2 passed it, so the empty bound reached the server, which refuses the query outright — the whole view failed to load and the filters already applied stopped applying. The saved-view fold persisted the same half-range, returning the refusal on every later read. `ViewFilterRuleSchema` cannot catch it: it accepts `["2024-01-01", ""]` because it counts the two slots, not what is in them, so authoring validation is green on exactly the shape that fails at query time. Refusing to emit it is therefore the producer's job. `isFilterValueComplete(operator, value)` is exported from the builder — the component that decides when one of its rows is finished, as it already does for the value-less operators — and both consumers read it instead of keeping copies. Bounds of `0` and `false` stay real bounds. Six operator labels (`startsWith`, `endsWith`, `isNull`, `isNotNull`, `exists`, `notExists`) were missing from all ten locale packs, so i18next resolved them to the raw key. The component's defaults table serves only the no-provider path, and the Console mounts a provider. The label key is built dynamically, so the call-site checker sees only a prefix and cross-pack parity is satisfied when all ten packs are missing a key together; a new parity test pins the packs against `FILTER_BUILDER_OPERATORS` in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
…ween` The spelling-normalization test fed the scalar `'x'` to every builder operator, including `between`. Since the fold drops a range that is not filled in, a bare scalar is now correctly dropped and the operator vanished from the output. Re-spelled rather than exempted: `ViewFilterRuleSchema` refuses `between: 'x'` outright, so the rule this fixture used to produce was never spec-valid. The test keeps asserting what it is named for — camelCase to canonical spelling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…terbuilder-between-i18n
CI red on
|
| version | shape keys | $like / $ilike |
|---|---|---|
17.0.0-rc.6 |
16 | absent |
17.0.0 GA |
18 | present |
The #2942 gate derives its vocabulary from that schema, so two new tokens with no builder route turn it red. The gate is behaving exactly as designed — this is a real product gap, not a broken test.
It is also the same shape the test's own header already documents: $icontains sat in KNOWN_UNREACHABLE "from objectui#3560 until objectui#4023: the spec gained it between 17.0.0-rc.2 and rc.5, and no builder operator could author it."
Why it looked like mine: this branch was cut from 21e4585, before the bump, so it pinned rc.6 and was green locally. CI tests the PR merge commit, which carries main's GA lockfile — so the PR inherited main's red.
Why I am not fixing it here
- Already filed and already routed: FilterConditionField cannot author spec 17.0.0 GA's new
$like/$ilike— the same shape as the closed$icontainsgap (#4023) #4911 is open withneeds-user-decision. Fixing it here would duplicate a claimed card and pre-empt a decision the maintainer has been asked to make (which builder operator should author$like/$ilike, and whether that is a new user-visible dropdown entry needing a name plus ten locale labels). - It lands in the shared operator vocabulary —
defaultOperators/condToMongo— which is exactly the carve-out this dispatch said becomes its own card rather than a silent widening of this diff. - Adding the tokens to
KNOWN_UNREACHABLEwould delete the alarm rather than fix anything, so that was never on the table.
Verification at 1563d47 (post-merge, against GA spec)
The measurement this PR rests on is unchanged under GA — ViewFilterRuleSchema still accepts ['2024-01-01', ''] while rejecting a scalar and a one-element array — and VIEW_FILTER_PAIR_VALUE_OPERATORS is still ['between'], so filterValueArity is unaffected by the bump.
| suite | result |
|---|---|
packages/fields/ (+ this PR's suites) |
108 files / 1832 passed — only red is #4911 |
packages/components/ + packages/i18n/ + packages/plugin-list/ |
236 files, 2804 passed |
packages/app-shell/src/views/ |
259 files, 2449 passed, 1 skipped |
Corrected sweep. The earlier sweep was scoped to app-shell, plugin-list, apps/, examples/, e2e/ and did not include packages/fields/. That boundary was the real miss, so this round ran the whole of packages/fields/ rather than grepping it: 108 files pass and the only failure is the pre-existing one. No non-pair between fixture was found anywhere in packages/.
Generated by Claude Code
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…terbuilder-between-i18n
Merged current
|
between value |
verdict on 17.0.0 |
|---|---|
['2024-01-01', '2024-03-01'] |
accepted |
['2024-01-01', ''] |
accepted — still invisible to authoring validation |
'2024-01-01' |
rejected |
['2024-01-01'] |
rejected |
VIEW_FILTER_PAIR_VALUE_OPERATORS is still ['between'] and VIEW_FILTER_LIST_VALUE_OPERATORS still ['in', 'not_in'], so filterValueArity is unchanged by the merge and the fix's basis is intact. The PR's premise did not move.
Composition with #4978 — no interaction, no overlap
KNOWN_UNREACHABLE on the merged branch is new Set(['$eq', '$between', '$like', '$ilike']), exactly as #4978 left it.
- This branch does not touch that file. Its 19 changed paths do not include
packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts, so there is nothing to collide with fix(ga-pin): quick-reference 两行跟到 GA;$like/$ilike 按「未裁」引用式豁免(#4977) #4978 or with theclaude/issue-4911-like-ilike-harvestseat. - ⛔ I did not alter the
$like/$ilikeentry or its comment. Worth flagging for the harvest seat: on mergedmainthat paragraph still reads "undecided — see FilterConditionField cannot author spec 17.0.0 GA's new$like/$ilike— the same shape as the closed$icontainsgap (#4023) #4911", and its own stated harvest condition ("ruled B: rewrite this paragraph as the refusal the ruling makes it") is therefore still outstanding. That is FilterConditionField cannot author spec 17.0.0 GA's new$like/$ilike— the same shape as the closed$icontainsgap (#4023) #4911's harvest, not mine, so I left it exactly as found. - The two guards are orthogonal by vocabulary:
KNOWN_UNREACHABLEholds spec$-tokens, while this PR's parity test holds builder operator ids (FILTER_BUILDER_OPERATORS) against locale-pack labels. Neither reads the other's set. - Checked the one real merge interaction: whether
mainhad added builder operators my locale packs would then lack labels for. It had not — the vocabulary is unchanged at 22 operators, and the parity test is green in both directions.
Full re-run at 6f7667f
| suite | result |
|---|---|
packages/fields/ |
104 files, 1720 passed — reachability gate now green |
packages/components/ |
149 files, 1368 passed |
packages/i18n/ + packages/plugin-list/ |
87 files, 1438 passed |
packages/app-shell/src/views/ |
259 files, 2449 passed, 1 skipped |
turbo run type-check (those five packages) |
34 tasks successful |
turbo run lint (same five) |
0 errors (2380 pre-existing warnings) |
check:i18n-keys / -drift / -dead-keys / check:control-bytes / check:phantom-deps / check:self-import |
PASS |
check-changeset-presence.mjs |
PASS — 18 source files, 4 released packages, 1 changeset |
Counts moved slightly against the pre-merge run because main added files (components 148 to 149, i18n+plugin-list 1436 to 1438 tests); all additions pass. Nothing was weakened or exempted to reach green.
Generated by Claude Code
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
Fixes objectstack-ai/objectstack#8815
Console list-filter panel, both halves of the card. Verified against
origin/mainbefore implementing — the card's premise held only partly, and the surviving part was not where the card pointed.Half ① —
between— I took the REAL FIX, not the "hide the operator" fallbackBecause the pair render already exists. objectui#3958 (commit
2b50261, 2026-08-16 04:42Z) landed the two-bound range input the day after this card was filed. Adatecolumn's 「介于」 already draws two date inputs on currentmain. Hiding the operator would have removed a control that works.What actually survived is the submission half. Typing only one bound produces
["2024-01-01", ""], and both write paths asked "is this row filled in?" with one shape-blind predicate:Correct for
scalarandlist, wrong forpair. An array of length 2 passes it, so the empty bound went to the server, which refuses the query outright (400 INVALID_FILTER) — the list showed 「该视图的查询被拒绝」 and, because the refusal takes the whole view, the filters the user had already applied stopped applying too.foldFilterGroupToSpecRulespersisted the same half-range, so the refusal returned on every later read of that view, for every user of it. That is the card's reported symptom, still live.The spec cannot intercept it. Measured against
ViewFilterRuleSchemaon@objectstack/spec17.0.0:['2024-01-01', '2024-03-01']['2024-01-01', '']'2024-01-01'['2024-01-01']Authoring validation is green on exactly the shape that fails at query time, one layer too late to be actionable. That makes refusing to emit it the producer's job — contract-first, no consumer-side tolerance.
Fix.
@object-ui/componentsexportsisFilterValueComplete(operator, value), arity-aware via the samefilterValueArityfold the rest of the file uses. It lives beside the render that creates the shape because this component is what decides when one of its rows is finished — exactly as it already ownsVALUELESS_FILTER_BUILDER_OPERATORS, and for the same measured reason: each consumer keeping its own copy is how the two came to disagree. Both consumers (plugin-list'sconvertFilterGroupToAST,app-shell'sfoldFilterGroupToSpecRules) now read it. A half-filled range is dropped exactly as a half-typedequalsrow already was: no filter, rather than a filter the server rejects. Bounds of0andfalsestay real bounds (objectui#4873, one column over).Half ② — operator labels: genuinely missing keys, and there are SIX, not four
Confirmed missing from all ten locale packs, not present-but-unreached:
startsWith,endsWith,isNull,isNotNull,exists,notExists. i18next resolves a missing key to the key itself, so the dropdown showedfilterBuilder.operators.isNullbeside translated entries.The component's own
createSafeTranslationdefaults table has all six in English and could not help: that table serves only the no-provider path, and the Console mounts a provider, so the pack's answer is the one that renders.Why the card says four. A
datecolumn's bucket offers the four nullness operators and notstartsWith/endsWith. The reporter opened a date field. Atextcolumn shows all six. The extra two are the same defect, same mechanism, same file surface, and the correct English form was already pinned by the component's defaults table — fixed here rather than filed, and named explicitly rather than slipped in.Why no gate caught it. The label key is built dynamically:
scripts/check-i18n-call-site-keys.mjsclassifies a template key asmissing-prefixand asks only whether the prefix resolves — it does, sixteen members deep. It cannot know which members a dynamic key needs.all-locales-key-parity.test.tsasks whether the ten packs agree. Its own header already records why it is blind here: "Ten packs identically missing it is full parity."So a new parity test pins the packs against
FILTER_BUILDER_OPERATORSin both directions — every drawable operator is labelled, and no pack carries a label for an operator the dropdown cannot draw. An operator added to the dropdown now fails loudly until every pack labels it. It lives inpackages/componentsbecause the dependency graph forces it:componentsdepends oni18n, so the reverse import would be a cycle.Carve-out clause — not triggered. The vocabulary half stayed in the render layer.
defaultOperators/OPT_IN_OPERATORS/ the shared operator registry are untouched; only locale packs gained entries.Fixture triage
viewFilterFold.test.ts's spelling-normalization test fed the scalar'x'to every builder operator includingbetween, so the guard correctly dropped that row and the operator vanished from its output. Re-spelled to a real pair rather than exempted:ViewFilterRuleSchemarefusesbetween: 'x'outright, so the rule that fixture used to produce was never spec-valid. The test still asserts what it is named for. Swept the rule's whole consumption radius for other non-pairbetweenfixtures — none inapp-shell,plugin-list,apps/,examples/ore2e/.Verification — all at
7521ae8(final commit)Run from the repo root per AGENTS.md §9 (never
pnpm --filter test, which silently runs another package's files).vitest rununion (9 files: new + adjacent filter/i18n parity)packages/components/packages/i18n/+packages/plugin-list/packages/app-shell/src/views/turbo run type-check(components, plugin-list, app-shell, i18n)turbo run lint(same four)check:i18n-keys/check:i18n-drift/check:i18n-dead-keys/check:control-bytescheck-changeset-presence.mjsReverse verification (fix committed first, then reverted, then restored — direction predicted before running):
[]"nothing filled in" and[0, 100]cases stayed green before the fix, confirming the change is narrowly scoped to the half-filled shape and did not re-judge anything already handled.A changeset is included (
@object-ui/components,@object-ui/plugin-list,@object-ui/app-shell,@object-ui/i18n, all patch) — this is user-visible, so no empty-frontmatter declaration and noskip-changesetlabel, which does not exist in this repo.Generated by Claude Code