Repository navigation
fix(plugin-list,components,i18n): list filter starts on the first visible column, hides hidden fields, one empty check where empty and null match the same records (objectui#11810) - #11836
Conversation
…le column, drops hidden fields, offers one empty check per null-only type (objectui#11810) ListView hands its FilterBuilder the object definition's field map minus the fields marked hidden (kept only when filterableFields names one or a held condition filters on it), ordered: the view's columns as the grid shows them, other business fields, system fields (isSystemManagedField). "Add filter" seeds on that list's first entry, so a new condition starts on the first visible column instead of the hidden organization_id. FilterBuilder's operator dropdown no longer offers is_null / is_not_null on a column whose type the spec's expandEmptyOperator rates null_only: there is_empty matches exactly the same records in every dialect the builder writes. Text and list-valued columns keep both pairs. The row's own operator stays mounted, so a stored is_null still loads, and operatorsForFieldType (what a row can hold) is unchanged. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…a column keeps both (objectui#11810) On a text-like or list-valued column the operator dropdown keeps both empty-check pairs, and now closes with one line saying how they differ: "Is empty" also matches blank text (or an empty list); "Is null" matches only a missing value. The arm is the spec's expandEmptyOperator; the operator labels are interpolated, not restated. New language-pack keys filterBuilder.emptyCheckHint.text / .list in all ten packs; the de quote-pairing census moves 71 -> 75 for their spans. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…row instead of any (objectui#11810) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…bjectui#11810) The two new language-pack keys widen @object-ui/i18n's published packs, and @object-ui/components ships the hint that reads them, so both declare minor (the precedent objectui#11645 set for added keys). plugin-list's part is a fix and stays patch; the fixed group versions all three together at the group's highest level. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Inputs read: card #11810 (body and all five comments, 6043301317 triage / 6046638726 claim / 6048143784 dev report / 6048182477 claim amendment / 6048273522 dev addendum), PR #11836 (body, 17-file list, net diff against ① Derived judgmentsAccept-set changes (what the end-user list and the shared builder read and offer):
Public-surface changes (what a published package's accept set gains or claims):
② Semver level
③ Boundary flags
What clears this record: one commit on the branch carrying the manifest line and the changeset sentence from ①.10, after which a fresh Implemented-by: VERDICT: FAIL |
…andEmptyOperator (objectui#11810) The published entry now imports expandEmptyOperator from @objectstack/spec/data, which the spec first exports in 17.5.0; ^17.0.0 admitted 17.0-17.4, where the import breaks. pnpm-lock.yaml regenerated with pnpm install --lockfile-only: the importer specifier line only. The changeset names the raise. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Inputs: card #11810 (body and all seven comments: triage 6043301317, claim 6046638726, reports 6048143784 / 6048273522 / 6048846867, amendments 6048182477 / 6048666363), PR #11836's body and 19-file list, the net diff ① Derived judgmentsCheck-runs on this head — the gate verdicts. 45 check-runs, all completed: 42
Scope against the claim. The 19 files are exactly the claim's File surface as amended twice: ② Semver level
③ Boundary flagsFrom report 6048143784:
From report 6048273522:
From report 6048846867:
From the PR's Acceptance notes:
Nothing left unanswered; nothing further to escalate for this head. Implemented-by: VERDICT: PASS Rendered 2026-10-07T23:49Z on the inputs named above. |
…d by its predicate says why it is unavailable (objectui#11811) (objectstack-ai#11841) Fixes objectstack-ai#11811 Clause-②: yes A record action greyed out by its declared `disabled` predicate now says why. It shows the generic reason "Not available for this record" as a tooltip, on hover and on keyboard focus, and the same text is its accessible description (`aria-describedby`). Before, the button carried no tooltip, no `title` and no description. ## Where the card's defect actually lives The claim's original Responsibility line named `DeclaredActionsBar` as the code that draws the showcase Task page's *Archive*. It does not: `DeclaredActionsBar` renders only `sys_approval_request` actions (record page approvals bar, approvals inbox). Measured on objectstack `main` (the card's `879bd38c` is its ancestor), the Task page (`showcase_task_detail`, `kind: 'full'`) draws Archive in two other renderers: - **the header**: a `page:header` node whose `actions` are ids, drawn by `PageHeaderRenderer`'s `renderButton` (`@object-ui/components`); - **the section bar**: `record:quick_actions` at `location: 'record_section'`, drawn by `QuickActionButton` (`@object-ui/plugin-detail`). The seat recorded the correction in claim amendment 6049093782 and added the header file to the surface. `DeclaredActionsBar` has the same defect class, so it gets the same fix. ## What changed - `page:header` (`packages/components/src/renderers/layout/containers.tsx`): the inline button gets a tooltip trigger plus an `sr-only` description. In the ⋯ overflow menu the reason is a visible second line under the label and is the item's `aria-describedby`; the item's name stays the label (`aria-labelledby`). A tooltip there would be out of keyboard reach: the menu's roving focus skips a disabled item (`focusable: !disabled`) and the menu traps Tab. **Only an authored action's declared `disabled`** gets the reason. The host-injected Edit and Delete (`RecordContext.headerSystemActions`, a host-computed boolean) and the inline-edit lock are unchanged. Authored and host actions are told apart by key, from the dedupe that lets an authored action win. - `record:quick_actions` (`QuickActionButton`) and `DeclaredActionsBar`: the same tooltip trigger and description on the inline button. - One new key, `actions.notAvailableForRecord`, with real translations in all ten packs. **Why a wrapper span.** A natively `disabled` button fires no pointer or focus events, and the Button primitive adds `disabled:pointer-events-none`, so a tooltip or a native `title` on the button itself never opens. The span is the tooltip trigger, the pattern Radix documents for a disabled trigger. It takes the hover, and `tabIndex={0}` lets a keyboard user focus it. Both the button and the span carry `aria-describedby` pointing at a persistent `sr-only` copy, so a screen reader gets the reason with the tooltip closed. The repo had no disabled-tooltip pattern. Its `title`-on-disabled-Button sites cannot show at all; a Chromium probe found that the hit target at a `pointer-events: none` disabled button's centre is its parent. **Not changed:** a button greyed out only while its own action runs (spinner) shows no reason. No export, prop or type member is added. The author-written reason beside the predicate is a spec key for objectstack, not part of this PR. ## Tests (head `e165c34`) - New pins, three files: `page-header-disabledReason-11811.test.tsx` (7), `record-quick-actions.disabledReason-11811.test.tsx` (7), `DeclaredActionsBar.disabledReason-11811.test.tsx` (7). - **Disabled by the predicate:** the button is disabled and described by the reason; hover opens `role=tooltip`; Tab reaches the trigger, which opens it. - **Controls:** a predicate that does not hold gives no reason; a running or loading button gives no reason; the header's host Edit and Delete with `disabled: true` give no reason. - **Overflow:** the item shows the reason and is described by it. - **Text:** the zh pack and the en pack. - **Fixture:** the predicate is the served CEL envelope. The bare string goes to the legacy evaluator, where `has()` faults and a fail-soft `disabled` greys out both rows, so it would pin the wrong reason. - `vitest run packages/components/` plus the 36 app-shell, console and example files that name `page:header`, `PageHeaderRenderer` or `headerSystemActions`, at `e165c34`: `Test Files 401 passed | 1 skipped (402)`. - `vitest run packages/plugin-detail/ packages/i18n/` plus the 27 app-shell and console files that name `DeclaredActionsBar`, `record-quick-actions` or the key, at `e2a498f`: `Test Files 356 passed | 1 skipped (357)`. `e165c34` changes no file in those packages. - type-check: `@object-ui/components` exit 0 at `e165c34`; `@object-ui/i18n`, `@object-ui/plugin-detail` and `@object-ui/app-shell` exit 0 at `e2a498f`, on dists rebuilt from this branch. - **Ablations**, run after commit through objectstack's `scripts/ablation-replace.mjs`: the anchor `if (!disabledReason) return button;` is replaced by an unconditional `return button;`. - Header: `Tests 4 failed | 3 passed (7)`. Red: description, hover, focus, zh. Green: overflow and both controls. - Quick actions and `DeclaredActionsBar`: `Tests 10 failed | 4 passed (14)`. Red: description, hover, focus, zh, en on each. Green: the controls. - Each restore was proven: blob equals HEAD and `git diff HEAD` is empty. - Gates at `e165c34`, all exit 0: - i18n: `check:i18n-keys`, `check:i18n-drift` (1 key added, no en value changed), `check:i18n-dead-keys` (report; the new key is not a candidate), `check:i18n-designer-parity`; - changeset: `check-changeset-presence` (4 released packages, 1 changeset), `check-changeset-no-major`, `check-changeset-fixed`, `check:changeset-claims`, `check:pending-changeset-literals`; - other: `check:control-bytes`, `check:test-path-roots`, `check:new-line-citations` (0 new); eslint on touched files (0 errors). - **Not run locally:** the full app-shell suite. The named-file sets above cannot see a suite that draws these renderers through a page tree without naming them; CI runs the full farm. ## Changeset `.changeset/11811-disabled-action-reason.md`: `@object-ui/i18n` and `@object-ui/components` are `minor`, `@object-ui/app-shell` and `@object-ui/plugin-detail` are `patch`. The fixed group publishes all of them at `minor`, never `major`. The body names the three surfaces and the one key. ## Acceptance notes - The generic action renderers (`action:button` / `action:icon` / `action:group` / `action:menu`) have the same gap. They are their own family card, objectui#11839, and this PR leaves them alone. - A `disabled` predicate that faults (fail-soft) also greys the action out, and now also shows the generic reason. That is consistent, since the action is unavailable either way, but the wording slightly overstates the case for a broken predicate. - `InlineEditSaveBar`'s Save button sets `title` on a disabled Button, which cannot show for the pointer-events reason above. The approval-lock band already shows that reason on screen. Noted only. - Overlap: objectstack-ai#11836 adds keys to the same ten packs. Whoever lands second merges `main`. The session behind this change is `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #11810
Clause-②: yes
The claim was amended to
Clause-②: yesin comment 6048182477, because the two new language-pack keys below widen@object-ui/i18n's published packs. Implemented by the dispatched dev on this branch, sessionsession_01CGZy1BGCjdN5cXqL9cnvB8.What changes
List view (
@object-ui/plugin-list,ListView'sfilterFields). The Filter panel's field list was the object definition's field map verbatim, and the served map puts the injected system columns first. Now:hidden: true(organization_id,owning_business_unit_id, the__searchcompanion) is not offered, unless the view names it infilterableFieldsor a condition the panel already holds filters on it (a filter restored from the per-user cache, possibly saved while "Add filter" still defaulted to Organization; without this its field trigger would draw blank);effectiveFields: after FLS, user-hidden columns andfieldOrder), then the other business fields, then system fields (the sharedisSystemManagedFieldfrom@object-ui/types, which reads the spec'ssystemflag), then a hidden field kept by the rule above. The definition's own order holds inside a tier.addConditionreads the first entry offields), so a new condition now starts on the view's first visible column. No default is chosen anywhere else.Builder (
@object-ui/components,FilterBuilder). The operator dropdown mountsofferedOperatorsForRow(module-private):operatorsForFieldTypeminusis_null/is_not_nullon a column whose type the spec'sexpandEmptyOperatorratesnull_only. The row's own operator is always mounted, so a storedis_nullstill loads as "Is null".operatorsForFieldType(what a row can HOLD) is unchanged.Hint (
@object-ui/components+@object-ui/i18n). Where both pairs stay, the operator list ends with one line saying how they differ. The operator labels are interpolated, not restated.The per-type table
Read from
@objectstack/specexpandEmptyOperator(17.7.0), the function every server face expands$emptywith. Not a local list.typealone)null_onlytext''multi_value[]The pair that stays on
null_onlyisis_empty/is_not_empty: every bucket that has an empty check offers it, so one label means one thing across columns, and every dialect the builder writes lowers it to$empty(Mongo criteria inFilterConditionField,ViewFilterRulein saved views and the dataset inspector, the ASTisemptyin the live grid).Callers of
FilterBuilderplugin-listListView(end-user list toolbar): field list, order and default change here; operator offer as above.fieldsFilterConditionField(sharing-rule criteria): offer narrows the same way. It loses no predicate: on anull_onlycolumn{ $empty: true }matches what{ $null: true }matches. A stored$nullreads back asis_nulland stays mounted.app-shelldataset inspector andmetadata-adminwidgets,config-field-renderer, the SDUIfilter-builderrenderer: same offer change. The dataset read-back (builderHolds,readBackOperator) asksoperatorsForFieldType, which is unchanged, so a stored$nullon a select column stays editable in the visual builder. Control: thedatasetFilterCondition*suites pass unchanged.Public surface
No export, prop or type member is added.
@object-ui/componentsraises its@objectstack/specfloor from^17.0.0to^17.5.0, because its published entry now importsexpandEmptyOperator, which the spec first exports in 17.5.0 (pnpm-lock.yamlmoves only that importer's specifier line).offeredOperatorsForRow,emptyCheckArmand the hint renderer are module-private. Two NEW language-pack keys in all ten packs:filterBuilder.emptyCheckHint.textandfilterBuilder.emptyCheckHint.list.Changeset
.changeset/11810-filter-builder-fields.md:@object-ui/i18nminor(the added keys, as in objectui#11645),@object-ui/componentsminor(it ships the hint that reads them),@object-ui/plugin-listpatch(its part is a fix). All three are in thefixedgroup, so they are versioned together at the group's highest level,minor.pnpm exec changeset statuslists the group atminor, with no mixed-changeset error.Tests
Head
24a52a4. All runs go from the worktree root through the shared verify lock. The code union below ran on3c5fe54. Since then no source file has changed:78fb73bsets the changeset levels, and24a52a4raises@object-ui/components'@objectstack/specfloor (git diff --stat 78fb73b 24a52a4: thepackage.jsonrange, thepnpm-lock.yamlimporter specifier line, one changeset sentence).On
24a52a4:node scripts/check-spec-range-floors.mjs(check:spec-floors), run after building@object-ui/componentsand@object-ui/plugin-listwith their dependencies: 0floor-too-low, and both packages are judged clean ("Inspected 21 of 39 published package(s) — 823 artifact file(s) … 304 (subpath, symbol) pair(s) judged"). The run still exits 1, but only for 14no-artifactfindings: packages this branch does not touch and that were not built locally (app-shell, auth, cli, layout, plugin-chatbot, plugin-dashboard, plugin-designer, plugin-detail, plugin-form, plugin-gantt, plugin-grid, plugin-timeline, plugin-tree, plugin-view). Control on78fb73b, same artifact, before the raise: "@object-ui/components[floor-too-low] packages/components/dist/index.js referencesexpandEmptyOperatorfrom @objectstack/spec/data, which @objectstack/spec@17.0.0 does not export", and the gate suggested^17.5.0.pnpm --filter @object-ui/components run type-check0.pnpm check:changeset-claims0 ("No pending changeset names a file this change touches.").pnpm check:pending-changeset-literals0.node scripts/check-changeset-presence.mjs0.pnpm changeset:check0.pnpm check:lockfile-integrity0 ("VERDICT clean — no @objectstack/* identity moved backward and no package gained a copy.").pnpm check:lockfile-dedupe0 ("VERDICT deduped").On
78fb73b(changeset levels), everything that reads a changeset was re-run:pnpm check:changeset-claims0, "Every package declared across those 1 body(ies) is either not negated …";pnpm check:pending-changeset-literals0, "No test source names a pending changeset.";node scripts/check-changeset-presence.mjs0, "16 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)";node scripts/check-changeset-no-major.mjs0, "No changeset declares amajorbump.";node scripts/check-changeset-fixed.mjs(theChangeset Fixed Group Checkjob) 0, "All workspace packages are in the changeset fixed group." / "privatePackages declared: version=true, tag=false.";pnpm changeset:check0;pnpm exec changeset status0.vitest runover the seven changeset-reading script suites (body-dialect-census,changeset-polarity-census,check-changeset-claims,check-changeset-fixed,check-changeset-no-major,check-changeset-overwrite,check-changeset-presence):Test Files 7 passed (7),Tests 299 passed (299),VERDICT command-exit 0.On
3c5fe54:pnpm exec vitest run --maxWorkers=2 packages/components/ packages/plugin-list/ packages/i18n/plus the cross-package filter suites (FilterConditionField*,datasetFilterCondition*,filter-builder-protocol-ids-census-9306,drill*Dialect*,drillEmptyBucketEscapeHatch-9159,view-operator-builder-parity,filter-builder-mirror-6939):Test Files 577 passed | 1 skipped (578),Tests 6746 passed | 37 skipped (6783),VERDICT command-exit 0.pnpm --filter @object-ui/components --filter @object-ui/i18n --filter @object-ui/plugin-list run type-checkafterturbo run build --filter='@object-ui/plugin-list^...': exit 0 (all three echoedtsc --noEmit && tsc -p tsconfig.test.json; the test projects include the new test files).pnpm exec eslinton the 16 touched TS files: 0 errors; warnings equal to the base file forListView.tsx(188 = 188) andfilter-builder.tsx(19 = 19), 0 on the new test files.check:control-bytes,check:test-path-roots,check:changeset-claims,check:pending-changeset-literals,check:i18n-keys,check:i18n-drift,check:i18n-dead-keys,check:i18n-designer-parity,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:new-line-citations,check:phantom-deps,check:esm-specifiers,scripts/check-changeset-presence.mjs,scripts/check-changeset-no-major.mjs.check:eager-closureandcheck:eager-locale-catalogues(PREREQUISITE NOT MET: they read a built console bundle; declared to CI). Marginal cost measured instead:@objectstack/spec/datais already in the eager closure throughrenderers/layout/containers.tsx(isFileIdToken), and an esbuild probe of that import with and withoutexpandEmptyOperatormoves 641429 to 641645 bytes minified (+216), 152073 to 152167 gzip (+94).Pins
packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx— the Tasks definition rebuilt from the spec'sinjectedSystemColumnDefs,task.object.ts's declared fields and the__searchcompanion (25 fields, lit control): no hidden field; order columns, then business fields, then system fields; "Add filter" starts on "Title";fieldOrderis followed; a held condition onorganization_idkeeps it last and its trigger reads "Organization";filterableFieldsnaming a hidden field keeps it.packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx— the arm table read from the spec (lit control); ninenull_onlytypes offer one pair and no hint; text types offer both pairs and the blank-text hint; list types both pairs and the empty-list hint; a storedis_nullon a select shows "Is null" (control); a stored camelCaseisNotNullloads; a text row on "Is null" switched to a select keeps it;operatorsForFieldTypestill carries both pairs (author-facing callers' hold set, control).filter-builder-operator-alias-trigger-7561.test.tsx: the recorded number-column list loses "Is null" / "Is not null".de-quote-pairing-3876.test.ts: the census moves 71 to 75 for the hint's four quoted spans.Ablations (one-off, committed tree, restored by
git checkout HEAD --, restore proven by blob hash equal to HEAD and an emptygit diff HEAD)Direction predicted before each run: red.
filterFieldsreturnscandidateFieldsunfiltered: 5 of 6 field-list pins red, the fixture control green. "Add filter" readsOrganizationinstead ofTitle, the issue's symptom.offeredOperatorsForRowreturns the full bucket: 11 red (the ninenull_onlytypes, the stored-is_nullpartner check, the 7561 recorded list), 52 green.The first attempt at ablation 1 was a no-op: the landing check counted a multi-line anchor with
grep -cF(one pattern per line) and refused before any test ran. It was re-run with a single-line anchor and a marker count (0 to 1) as the landing proof.Acceptance notes
multiple: truelookups / users / selects. The builder's field descriptor carriestypeonly, so such a column is judged by its type (null_only) and offered "Is empty" / "Is not empty" without the null pair. On the server it ismulti_value, and "Is empty" there matches null or[], which is the reading an end user means. Offering both pairs with the list hint there would needmultipleon the descriptor, a new type member. Not done here.filterableFields(the spec calls it "fields enabled for end-user filtering", an explicit author choice), and one a held condition filters on (otherwise its field trigger draws blank). Both go last.candidateFields(relational fields excluded, the platform's sortability signal applied). Whether it offers the hidden__searchtext column on a served deployment was not measured. Carrier: none.Generated by Claude Code