Skip to content

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

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11810-filter-builder-fields
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11810-filter-builder-fields

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #11810
Clause-②: yes

The claim was amended to Clause-②: yes in 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, session session_01CGZy1BGCjdN5cXqL9cnvB8.

What changes

List view (@object-ui/plugin-list, ListView's filterFields). 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:

  • a field the definition marks hidden: true (organization_id, owning_business_unit_id, the __search companion) is not offered, unless the view names it in filterableFields or 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);
  • order: the view's columns as the grid shows them (effectiveFields: after FLS, user-hidden columns and fieldOrder), then the other business fields, then system fields (the shared isSystemManagedField from @object-ui/types, which reads the spec's system flag), then a hidden field kept by the rule above. The definition's own order holds inside a tier.
  • "Add filter" seeds a row on the list's first entry (addCondition reads the first entry of fields), 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 mounts offeredOperatorsForRow (module-private): operatorsForFieldType minus is_null / is_not_null on a column whose type the spec's expandEmptyOperator rates null_only. The row's own operator is always mounted, so a stored is_null still 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/spec expandEmptyOperator (17.7.0), the function every server face expands $empty with. Not a local list.

arm types (by type alone) "Is empty" matches offered hint
null_only number, currency, percent, date, datetime, time, boolean, toggle, select, radio, lookup, master_detail, tree, user, image, file, avatar, video, audio, formula, summary, autonumber, composite, repeater, record, location, address, json, rating, slider, progress, vector, and any unknown type null only (exactly what "Is null" matches) "Is empty" / "Is not empty" only none
text text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode null or '' both pairs "Is empty" also matches blank text; "Is null" matches only a missing value
multi_value multiselect, checkboxes, tags null or [] both pairs "Is empty" also matches an empty list; "Is null" matches only a missing value

The pair that stays on null_only is is_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 in FilterConditionField, ViewFilterRule in saved views and the dataset inspector, the AST isempty in the live grid).

Callers of FilterBuilder

  • plugin-list ListView (end-user list toolbar): field list, order and default change here; operator offer as above.
  • fields FilterConditionField (sharing-rule criteria): offer narrows the same way. It loses no predicate: on a null_only column { $empty: true } matches what { $null: true } matches. A stored $null reads back as is_null and stays mounted.
  • app-shell dataset inspector and metadata-admin widgets, config-field-renderer, the SDUI filter-builder renderer: same offer change. The dataset read-back (builderHolds, readBackOperator) asks operatorsForFieldType, which is unchanged, so a stored $null on a select column stays editable in the visual builder. Control: the datasetFilterCondition* suites pass unchanged.

Public surface

No export, prop or type member is added. @object-ui/components raises its @objectstack/spec floor from ^17.0.0 to ^17.5.0, because its published entry now imports expandEmptyOperator, which the spec first exports in 17.5.0 (pnpm-lock.yaml moves only that importer's specifier line). offeredOperatorsForRow, emptyCheckArm and the hint renderer are module-private. Two NEW language-pack keys in all ten packs: filterBuilder.emptyCheckHint.text and filterBuilder.emptyCheckHint.list.

Changeset .changeset/11810-filter-builder-fields.md: @object-ui/i18n minor (the added keys, as in objectui#11645), @object-ui/components minor (it ships the hint that reads them), @object-ui/plugin-list patch (its part is a fix). All three are in the fixed group, so they are versioned together at the group's highest level, minor. pnpm exec changeset status lists the group at minor, 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 on 3c5fe54. Since then no source file has changed: 78fb73b sets the changeset levels, and 24a52a4 raises @object-ui/components' @objectstack/spec floor (git diff --stat 78fb73b 24a52a4: the package.json range, the pnpm-lock.yaml importer specifier line, one changeset sentence).

On 24a52a4:

  • node scripts/check-spec-range-floors.mjs (check:spec-floors), run after building @object-ui/components and @object-ui/plugin-list with their dependencies: 0 floor-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 14 no-artifact findings: 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 on 78fb73b, same artifact, before the raise: "@object-ui/components [floor-too-low] packages/components/dist/index.js references expandEmptyOperator from @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-check 0. pnpm check:changeset-claims 0 ("No pending changeset names a file this change touches."). pnpm check:pending-changeset-literals 0. node scripts/check-changeset-presence.mjs 0. pnpm changeset:check 0. pnpm check:lockfile-integrity 0 ("VERDICT clean — no @objectstack/* identity moved backward and no package gained a copy."). pnpm check:lockfile-dedupe 0 ("VERDICT deduped").

On 78fb73b (changeset levels), everything that reads a changeset was re-run:

  • pnpm check:changeset-claims 0, "Every package declared across those 1 body(ies) is either not negated …"; pnpm check:pending-changeset-literals 0, "No test source names a pending changeset."; node scripts/check-changeset-presence.mjs 0, "16 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)"; node scripts/check-changeset-no-major.mjs 0, "No changeset declares a major bump."; node scripts/check-changeset-fixed.mjs (the Changeset Fixed Group Check job) 0, "All workspace packages are in the changeset fixed group." / "privatePackages declared: version=true, tag=false."; pnpm changeset:check 0; pnpm exec changeset status 0.
  • vitest run over 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:

  • Union: 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-check after turbo run build --filter='@object-ui/plugin-list^...': exit 0 (all three echoed tsc --noEmit && tsc -p tsconfig.test.json; the test projects include the new test files).
  • pnpm exec eslint on the 16 touched TS files: 0 errors; warnings equal to the base file for ListView.tsx (188 = 188) and filter-builder.tsx (19 = 19), 0 on the new test files.
  • Gates, exit 0: 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.
  • NOT MEASURED: check:eager-closure and check:eager-locale-catalogues (PREREQUISITE NOT MET: they read a built console bundle; declared to CI). Marginal cost measured instead: @objectstack/spec/data is already in the eager closure through renderers/layout/containers.tsx (isFileIdToken), and an esbuild probe of that import with and without expandEmptyOperator moves 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's injectedSystemColumnDefs, task.object.ts's declared fields and the __search companion (25 fields, lit control): no hidden field; order columns, then business fields, then system fields; "Add filter" starts on "Title"; fieldOrder is followed; a held condition on organization_id keeps it last and its trigger reads "Organization"; filterableFields naming 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); nine null_only types 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 stored is_null on a select shows "Is null" (control); a stored camelCase isNotNull loads; a text row on "Is null" switched to a select keeps it; operatorsForFieldType still 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 empty git diff HEAD)

Direction predicted before each run: red.

  1. filterFields returns candidateFields unfiltered: 5 of 6 field-list pins red, the fixture control green. "Add filter" reads Organization instead of Title, the issue's symptom.
  2. offeredOperatorsForRow returns the full bucket: 11 red (the nine null_only types, the stored-is_null partner check, the 7561 recorded list), 52 green.
  3. The row-own clause removed: the three stored/held-operator controls red, each with a BLANK trigger (expected 'Is null', received '').

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: true lookups / users / selects. The builder's field descriptor carries type only, 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 is multi_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 need multiple on the descriptor, a new type member. Not done here.
  • A system field that is one of the view's columns sorts with the columns, in column order: the author put it in the view, and the list mirrors the grid. The "system fields last" tier is the system fields the view does not show.
  • Hidden fields that stay listed: one named in 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.
  • Sort picker, not measured. The Sort popover reads the same candidateFields (relational fields excluded, the platform's sortability signal applied). Whether it offers the hidden __search text column on a served deployment was not measured. Carrier: none.

Generated by Claude Code

claude added 4 commits October 7, 2026 21:45
…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>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 336 chunks) 3521.7 KB 3551.8 KB
Main entry chunk (gzip) 156.8 KB 350 KB
Entry file index-BeC4KSBW.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 580.87KB 139.80KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 235.41KB 65.46KB
fields (index.js) 266.88KB 67.46KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 38.37KB 10.31KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 199.63KB 47.46KB
plugin-dashboard (index.js) 144.82KB 39.17KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.86KB 65.27KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 176.62KB 45.75KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 239.75KB 65.92KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 117.42KB 29.32KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.07KB 22.93KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 78fb73bfd49a4537e79199e94840cee9e6eec7b5
Local-runs: none

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 main at the head above, merge-base 8aebc6f, 567 added / 19 removed lines), and the 43 check-runs on the head: 40 success, 3 skipped by design (Test (coverage), its shard matrix, dependabot), 0 failure, 0 in progress; the one commit status (Vercel, ignored build step) success. Head repo is the base repo. No governed path in the file list (.claude/**, skills/**, docs/adr/**, AGENTS.md, CLAUDE.md untouched), so this review is owed by the claim's amended Clause-②: yes (6048182477) alone. No earlier ## Contract review record exists on the PR or the card. The spec facts in ①.10 are read from the shared objectstack checkout (packages/spec history and CHANGELOG) and from objectui's scripts/check-spec-range-floors.mjs and its workflow at main; nothing was built, run or re-run.

① Derived judgments

Accept-set changes (what the end-user list and the shared builder read and offer):

  1. ListView.filterFields membership: a field whose served definition has hidden === true is dropped, unless filterableFields names it or a condition the panel holds filters on it. RIGHT: the spec declares hidden: z.boolean().default(false), so the strict test is the right one; the two keep-rules satisfy the card's "hidden fields are not offered" without blanking a restored row's field trigger (pinned: a held organization_id keeps its label and goes last, owning_business_unit_id stays absent). The filterableFields whitelist is applied first, as before ([console] List sort: filterableFields doubles as the sort whitelist, and a header click discards the view's multi-level default with no way back #4243), so a hidden field the author named is an explicit choice.
  2. Order: tier 0 the view's columns in effectiveFields order (after FLS, user-hidden columns and fieldOrder), tier 1 other business fields, tier 2 system fields through isSystemManagedField(name, def) (the spec system flag, then the name list), tier 3 a kept hidden field; the definition's order inside a tier. RIGHT against the triage direction ("fields follow the view's column order, system fields grouped last") and the signature in @object-ui/types system-fields.ts. A system field the author placed as a column sorts with the columns: the grid's own order, which is the card's "the view's columns, then the rest".
  3. Default: addCondition is unchanged and seeds on fields[0], so the first visible column leads and no second default is introduced. RIGHT (pinned: "Add filter" reads Title; ablation 1 reads Organization, the card's symptom).
  4. Builder offer: offeredOperatorsForRow is operatorsForFieldType minus is_null / is_not_null on a null_only arm, with the row's own operator (normalised through the read boundary) always mounted. RIGHT: the arm is @objectstack/spec's expandEmptyOperator (multi_value by isMultiValueField, text by STRING_VALUE_TYPES, else null_only), not a local list; every bucket that carries the nullness pair also carries is_empty / is_not_empty (text, number, date, select, lookup; boolean carries neither), so the pair that stays is offered wherever an empty check exists; the dialects lower is_empty to $empty (FilterConditionField, datasetFilterCondition, the grid AST's isempty), which the server expands to null-only on that arm, the same records as $null. The hold set operatorsForFieldType is unchanged (pinned), so builderHolds / readBackOperator and the plugin-list parity pin keep their answers, and a stored is_null on a select still loads (pinned, including the camelCase spelling).
  5. The hint: rendered only when the row's offer holds both is_empty and is_null AND the arm is not null_only (so a held is_null on a select draws none), the two labels interpolated from the operator keys rather than restated. RIGHT: the ruling's "then a hint says how", verbatim.
  6. The 7561 recorded number-column list loses the null pair; the de quote census moves 71 to 75 (two keys, two quoted spans each). RIGHT, both consequences of 4 and 8.
  7. A multiple: true lookup / user / select is judged null_only because the builder's field descriptor (FilterBuilderProps.fields) carries type and no multiple, so it is offered the is_empty pair only. ACCEPTED as right: that pair is the one whose server expansion counts [], which is the end user's reading; what is lost is only a fresh "Is null" on such a column (a stored one still loads). Adding multiple to the descriptor would be a new type member, correctly not done under this card. Noted for the seat; not a condition.

Public-surface changes (what a published package's accept set gains or claims):

  1. @object-ui/i18n: two new keys, filterBuilder.emptyCheckHint.text and .list, in all ten packs. PUBLIC (./locales and ./locales/* are in the package's exports) and RIGHT: additive only; every pack carries both the isEmpty and the isNull placeholder in both keys (check:i18n-designer-parity green); the nine non-en packs are translations, not copies (de in its own quote pair); the inline fallback in filter-builder.tsx matches the en text. Nothing is removed or renamed, so no migration text and no ADR-0087 disposition is owed.
  2. @object-ui/components: no export, prop or type member added. RIGHT: NULLNESS_PAIR, emptyCheckArm, offeredOperatorsForRow and the hint renderer are module-private, and operatorsForFieldType's contract is unchanged.
  3. @object-ui/components' declared @objectstack/spec range. WRONG. The diff adds import { expandEmptyOperator } from "@objectstack/spec/data" to filter-builder.tsx, a module of the published entry, and the package's build keeps every bare specifier external (vite.config.ts: its external function answers true for every id that is not relative), so the published dist/index.js now imports that symbol from the spec. expandEmptyOperator first shipped in @objectstack/spec@17.5.0: it was added in objectstack b810ddb6 while packages/spec/package.json read 17.4.0 (after 17.4.0 had been cut), and the spec CHANGELOG lists it under 17.5.0 and under no earlier version. packages/components/package.json still declares "@objectstack/spec": "^17.0.0" in dependencies, a range that admits 17.0.0 through 17.4.x, none of which export the symbol. A declared range is a public claim (objectui#5793, the ruling scripts/check-spec-range-floors.mjs is built on: floors track reality), and this repo's practice is to raise the importer's floor in the same PR and name it in the changeset (plugin-form ^17.0.0 to ^17.6.0 in feat(plugin-form): derive the inline grid default columns through the spec rule deriveInlineGridColumns (objectui#11345) #11623 for deriveInlineGridColumns; auth to ^17.3.0; app-shell to ^17.6.0 in feat(app-shell): register the ref:dataset spec-form widget, fed by the report inspector's dataset catalog (objectui#11601) #11611). The base is honest today: isFileIdToken, components' only other runtime import from @objectstack/spec/data, is in 17.0.0, and the nightly spec-range-floors workflow is green on main (latest scheduled run, head 77c12b9). This PR turns it red at the next nightly and at changeset:publish, whose script runs check-spec-range-floors.mjs before changeset publish, which blocks the whole fixed group's release. The head's check-runs cannot see it: that gate is deliberately not a pull_request job (its workflow header says so). The dev did not flag it, and the claim's File surface does not include packages/components/package.json.
    Remedy, one line and one sentence: in packages/components/package.json, "@objectstack/spec": "^17.5.0"; in .changeset/11810-filter-builder-fields.md, a sentence in the precedent's shape, "@object-ui/components raises its @objectstack/spec floor from ^17.0.0 to ^17.5.0, because its published entry now imports expandEmptyOperator, which the spec first exports in 17.5.0." The seat adds the manifest to the claim's File surface. @object-ui/plugin-list and @object-ui/i18n need no move: their published sources import nothing new from the spec (the plugin-list test's injectedSystemColumnDefs is not published).
  4. No REST path, query set, metadata schema or @objectstack/* import other than ①.10 changes; nothing in packages/spec-governed territory; nothing narrows. RIGHT.

② Semver level

③ Boundary flags

  1. open_questions[0] (6048143784), land the hint and its keys under an amended Clause-②: yes (A) or drop commit cedb17c and carry the hint as a follow-up (B): the seat answered A (6048182477). I judge A right: the hint is the ruling's own text, B would ship text and list columns with two unexplained pairs, the confusion the card reports, and the two keys are copy in an existing namespace, additive, pinned on both arms. Closed.
  2. deviations, files outside the claim's File surface (the ten packs and the de census pin): covered by the amendment's File-surface line. Closed. The same breach now recurs for packages/components/package.json (①.10) and needs the same amendment.
  3. deviations, Zone 2 not booted live; the fixture rebuilt from the spec's injectedSystemColumnDefs, task.object.ts and the __search companion, pinned at 25 fields as a lit control (organization_id first, the three hidden names present): ACCEPTED. The fixture reproduces the card's observed shape, and the lit control makes a drift in either source loud.
  4. deviations, check:eager-closure and check:eager-locale-catalogues NOT MEASURED locally: CLOSED BY CI. Bundle Analysis is success on the head and its comment on the PR (6048316806) reads PASS, eager closure 3521.7 KB against a 3551.8 KB budget (the dev's esbuild probe priced the new import at +94 B gzip).
  5. deviations, the first ablation attempt a guarded no-op, re-run with a single-line anchor: a process note, not a boundary. The three ablations went red in the predicted direction with the restore proven.
  6. Round-2 deviations (6048273522), main moved and the branch was not merged: not a condition. The PR reads mergeable: true / clean, and the gate verdicts are the head's own check-runs. main has moved again since; the push ①.10 asks for may rebase or not as the dev judges, since nothing in this diff touches what landed.
  7. Round-2 deviations, the PR body's footer form: no model identifier in the title, body, changeset, code comments or the four commits' trailers (checked). Not a contract matter. Closed.
  8. Acceptance notes: multiple: true, see ①.7, accepted; a system field that is a view column sorts with the columns, see ①.2, right; kept hidden fields go last, see ①.1, right; the Sort picker's hidden-field offer not measured, carrier none, together with the matching out_of_scope_findings entry: ESCALATED to the dispatching seat to decide whether ONE card is filed for it (the dev correctly did not POST issues; dedupe words as the report gives). Outside this card's step; not a condition.
  9. A flag the report did not raise: the spec floor, ①.10. This is the FAIL condition. The dev's gates were the per-PR families; the family that judges floors runs nightly and at release, so no local or CI signal reached the dev. The miss is the convention's (raise the floor with the import), not a measurement failure, and it costs one commit.
  10. The report's CI line (in_progress at report time): RESOLVED as in the inputs line above; nothing red, nothing pending.

What clears this record: one commit on the branch carrying the manifest line and the changeset sentence from ①.10, after which a fresh ## Contract review is owed on the new head (this record names this head only).

Implemented-by: claude/issue-11810-filter-builder-fields
Reviewed-by: session_01CGZy1BGCjdN5cXqL9cnvB8

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>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 336 chunks) 3522.8 KB 3551.8 KB
Main entry chunk (gzip) 157.0 KB 350 KB
Entry file index-ggVA_SZU.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 580.87KB 139.80KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 235.41KB 65.46KB
fields (index.js) 266.88KB 67.46KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 38.37KB 10.31KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 199.63KB 47.46KB
plugin-dashboard (index.js) 144.82KB 39.17KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.86KB 65.27KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 176.62KB 45.75KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 239.75KB 65.92KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 117.42KB 29.32KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.07KB 22.93KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 24a52a451bde48583a2ca26861cb906a9cc2526c
Local-runs: none

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 main...head (merge base is the claim's base commit; 5 commits, +571/-21), and the 45 check-runs on this head. Read with gh api and git show only; nothing built, run or re-run. The earlier record on this PR judged an earlier head; this one judges the head above on its own inputs.

① Derived judgments

Check-runs on this head — the gate verdicts. 45 check-runs, all completed: 42 success, 3 skipped by design (the two coverage lanes and dependabot), 0 failing, 0 pending. Changeset Bump Policy, Changeset Declaration, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report, Lockfile Integrity Check, Lockfile Dedupe Check, Type Check, Lint, Test shards 1–8, Test (dist pins), Build & E2E, Bundle Analysis and Governed Surface Queue Guard are all green. The console performance-budget comment rendered on this head (6048865063) reads the eager closure at 3522.8 KB against a 3551.8 KB budget, PASS.

@object-ui/plugin-list — ListView.filterFields (the field list, its order, the default).

  • Membership: a field the served definition marks hidden: true is dropped, except when the view names it in filterableFields or a condition the panel already holds filters on it. Right. The ruling says hidden fields are not offered; the two exceptions are the author's explicit whitelist (spec: fields enabled for end-user filtering) and a restored row whose trigger would otherwise draw blank. Both are pinned (ListView.filterFieldList-11810.test.tsx).
  • Order: tier 0 the view's columns in grid order (effectiveFields through columnIdentity, so FLS, user-hidden columns and fieldOrder are honoured), tier 1 the other business fields, tier 2 system fields via the shared isSystemManagedField (name list plus the spec system flag), tier 3 a hidden field kept by the rule above; definition order inside a tier. Right against the ruling (columns, then the rest, system last). The author-declared created_at lands in the system tier by name — consistent with the shared predicate, and pinned.
  • Default: unchanged mechanism (the builder seeds on the list's first entry), so "Add filter" now starts on the first visible column. Right — no new default surface, no new prop.
  • Public surface: none. No export, prop or ListViewSchema member changes. The only new @objectstack/spec/data import in this package (injectedSystemColumnDefs) is in the test file, so plugin-list's ^17.0.0 floor correctly does not move. Memo dependencies now include currentFilters.conditions, which the held-field rule needs. Right.

@object-ui/components — FilterBuilder.

  • The dropdown mounts offeredOperatorsForRow: operatorsForFieldType minus is_null / is_not_null where the spec's expandEmptyOperator rates the column's type null_only; the row's own operator (folded through normalizeFilterBuilderOperator) is always mounted. Right. On a null_only column is_empty and is_null are one predicate under two labels in every dialect the builder writes, so the end user loses a duplicate label and no predicate. The exported hold set operatorsForFieldType is unchanged, so app-shell's dataset read-back, fields' sharing-rule criteria and plugin-list's parity pin keep their accept-set; a stored is_null / camelCase isNotNull still loads and shows (pinned, and the blank-trigger regression of FilterBuilder 切换行的 field 后 operator 不重置 —— select 改 text 后 operator 可停留在下拉里已不存在的 in,trigger 显示空 #4768 / filter-builder declares one operator vocabulary and renders another: the mirror refuses every dropdown id but three, and every id it accepts but three draws a blank trigger #7561 is explicitly guarded). The recorded number-column list in filter-builder-operator-alias-trigger-7561.test.tsx loses the null pair — the expected consequence, not a drift.
  • The hint: a p element under the operator list, rendered only when both pairs are offered and the arm is text or multi_value, reading the two new keys with the operator labels interpolated. Right against the ruling's "then a hint says how", and it is pinned per arm.
  • A descriptor with no type falls to the text arm (both pairs plus the text hint) — conservative, keeps both predicates. Acceptable.
  • Public surface: offeredOperatorsForRow, emptyCheckArm, NULLNESS_PAIR and the hint renderer are module-private; no export, prop or type member is added. Right, and it matches the PR body's "Public surface" section.
  • Dependency floor: the published entry now imports expandEmptyOperator from @objectstack/spec/data, and packages/components/package.json raises @objectstack/spec from ^17.0.0 to ^17.5.0; pnpm-lock.yaml moves exactly that importer's specifier line (the resolved 17.7.0 is unchanged). Right. This is the defect the earlier record failed the branch on, fixed in the one commit this head adds. The floor is attested on the card by the dev's run of check-spec-range-floors.mjs on the built artifact with a before/after control (floor-too-low … suggested ^17.5.0 before; 0 floor-too-low after) and by the seat's independent measurement in 6048666363; sibling floors sit at ^17.6.0 (core) and ^17.7.0 (plugin-form, types). No check-run on this head judges it: the Spec Range Floors workflow is, by its own text, deliberately not a pull_request job (nightly alarm, with the blocking copy on pnpm changeset:publish), so the publish path stays the control. Lockfile Integrity and Lockfile Dedupe are green.

@object-ui/i18n — two new keys. filterBuilder.emptyCheckHint.text and filterBuilder.emptyCheckHint.list in all ten packs, under the existing filterBuilder namespace, each carrying both {{isEmpty}} and {{isNull}} placeholders in every pack (checked pack by pack in the diff). The package exports ./locales and ./locales/*, so the keys are published accept-set — an additive widening, which is exactly what makes Clause-② yes. Right. The de pack uses „“ quotes, so the quote-pairing census moves 71 → 75 (two values × two quoted spans); the pin is updated with its reason. The en fallback map in useSafeFilterTranslation carries the same two keys. Lint's i18n steps (call-site keys resolve, en changes followed by the nine translations, designer parity) are green.

Scope against the claim. The 19 files are exactly the claim's File surface as amended twice: ListView.tsx and filter-builder.tsx with their tests, .changeset/11810-filter-builder-fields.md, the ten packs and the de census test (6048182477), packages/components/package.json and pnpm-lock.yaml (6048666363). packages/components/src/ui/** is untouched. No governed path is in the file list (the queue guard is green and would not read this record in any case).

② Semver level

  • .changeset/11810-filter-builder-fields.md: @object-ui/plugin-list patch, @object-ui/components minor, @object-ui/i18n minor. Right. The rule is that a yes declaration takes at least minor: i18n's added keys are the widening and take minor (the console(marketplace): Installed Apps shows a package the runtime refused to load as installed — render the listing's not-loaded marker (the console half of objectstack-ai/objectstack#21822) #11645 precedent the card cites); components ships the hint that reads them and raises a consumer-visible dependency floor, which a patch would understate; plugin-list's part is a fix with no surface change. All three are in the one fixed group of 40 (checked in .changeset/config.json at this head), so the group versions together at minor; no major is declared (Changeset Bump Policy green). skip-changeset would be wrong here and is not used.
  • The changeset body names the two keys and the floor raise with its reason — the text that ships in CHANGELOG.md. It is not a breaking changeset, so no migration or ADR-0087 marker is owed.
  • The Clause-② line: the PR body carries the declaration Clause-②: yes at the start of its second line, with no arm (the closed pair is optional; the widening is additive). It matches the claim as amended (6048182477, before the PR opened) and matches what the diff publishes. Right.

③ Boundary flags

From report 6048143784:

  • open_questions[0] — the Clause-② stop (two new language-pack keys, the ten packs and the de census outside the File surface). Answered: amendment 6048182477 chose A, set Clause-②: yes and widened the File surface; this head ships the hint and keys accordingly.
  • Deviation — Zone 2 not booted live; the Tasks fixture rebuilt from the spec's injectedSystemColumnDefs, task.object.ts and the __search companion. Answered by the pin's lit control (25 fields, organization_id first — the card's observed shape), and the card's own Environment section is the measured reach. No live boot is owed by a read-only review.
  • Deviation — check:eager-closure / check:eager-locale-catalogues NOT MEASURED locally. Answered by the check-runs: Build & E2E's console performance-budget step is green on this head and its comment (6048865063) reads 3522.8 KB of 3551.8 KB; Docs Route Eager Closure Check is green.
  • Deviation — the first ablation attempt was a guarded no-op, re-run with a single-line anchor. Process note; no boundary.
  • out_of_scope_findings — the Sort picker may offer the hidden __search column; inferred, not measured; carrier none. Escalated and ruled: amendment 6048666363 declines to file it without a measured reach and keeps it in the PR's Acceptance notes. This diff does not change the Sort candidates, so nothing regresses here; a measured check belongs to the next QA pass.

From report 6048273522:

  • origin/main moved under the branch; not merged. Answered: the PR is mergeable: true, state clean against current main; the three-dot diff is the 19 files above.
  • PR body footer form. Cosmetic; not a contract matter.

From report 6048846867:

  • The floors gate ran locally on built components and plugin-list only; 14 no-artifact findings for unbuilt, untouched packages. Answered: the gate refuses to guess by design, the two touched packages are judged clean, and the blocking copy on the publish path covers every package at release.

From the PR's Acceptance notes:

  • multiple: true lookups / users / selects are judged by type alone (null_only) and offered only the is_empty pair, though the server arm is multi_value. Answered as a bounded residual: the pair kept is the one whose server expansion counts [] and null, a stored is_null still loads, and the only fix (a multiple member on the builder's field descriptor) is a new public type member outside this ruling. If the product wants both pairs there, that is a separate card with its own Clause-②: yes — left to the seat; nothing is owed on this PR.
  • A system field that is one of the view's columns sorts with the columns; a kept hidden field goes last. Both are the ruling's own reading, pinned.

Nothing left unanswered; nothing further to escalate for this head.

Implemented-by: claude/issue-11810-filter-builder-fields
Reviewed-by: session_01CGZy1BGCjdN5cXqL9cnvB8

VERDICT: PASS

Rendered 2026-10-07T23:49Z on the inputs named above.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 23:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 23:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 1e1f09e Oct 8, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11810-filter-builder-fields branch October 8, 2026 00:09
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

list filter builder: "Add filter" defaults to the hidden Organization field, lists hidden system fields first, and offers both "Is empty" and "Is null"

2 participants