Skip to content

fix(plugin-detail): the record detail header honors userActions predicates (#4419) - #4515

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4419-detail-header-predicates
Aug 13, 2026
Merged

fix(plugin-detail): the record detail header honors userActions predicates (#4419)#4515
yinlianghui merged 1 commit into
mainfrom
claude/issue-4419-detail-header-predicates

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#4419

The asymmetry

userActions.delete reached the record detail header in its boolean form but not in its predicate form.

formrow kebabdetail header
userActions: { delete: false }hiddenhidden — the host lowers the boolean into schema.showDelete
userActions: { delete: { visibleWhen: … } }hidden on excluded recordsstill offered

The header ANDed three gates and never evaluated the predicate (DetailView.tsx:306-307 on main):

showDelete: gatedSchema.showDelete && objectAllowsDelete && canDeleteRecord

So an author upgrading from "nobody may delete this object" to "these records may not be deleted" — the exact reason the object form exists — silently lost a surface. A key whose scope shrinks when you add a predicate to it is ADR-0078's silently-inert metadata, which is the framing the ruling adopted.

The fix — one evaluator, one answer

The predicate is folded in as a fourth conjunct, evaluated against the open record through the same helper family the row surfaces already use. No fresh predicate-evaluation code on this path:

  • userActionPredicates from @object-ui/core for the parse — the exact import RelatedList.tsx:1130-1131 makes, and the one plugin-grid's resolveRowCrudAffordances feeds the row kebab from. It is THE parser for the boolean/object form.
  • useRowPredicate from @object-ui/react for the evaluation. plugin-grid's evalRowActionVisibility — the body of isBuiltinRowActionVisible, which RowActionMenu.tsx:348-350 calls — documents itself as mirroring useRowPredicate(pred, row, { fallback: false, warnOnError: true, label, fields })"exactly, boolean short-circuit included", and is hook-free only because a row loop evaluates a variable number of actions inside one useMemo. The header evaluates a fixed arity of one record, so the hook form is that same evaluator without the constraint that shaped the wrapper. (isBuiltinRowActionVisible itself is not importable here: plugin-grid is not a plugin-detail dependency, and @object-ui/components' copy is not re-exported from its barrel.)

Posture copied verbatim from DataTableBuiltinRowActionItem:

  • visibleWhen false → the header affordance is hidden. Fails CLOSED; declared by != null, not truthiness, so visibleWhen: false is a gate rather than "ungated" (the objectui#3492 invariant).
  • disabledWhen true → the affordance renders disabled, not removed — kebab symmetry. Applied to the desktop Edit CTA and its sm:hidden overflow twin, plus the overflow Delete, so one predicate gets one answer at every breakpoint.
  • objectSchema.fields is passed to every predicate for the same reason ObjectGrid passes objectFields to RowActionMenu: a relation field must bind as the stored foreign key, not whatever $expand substituted on this surface.

The existing gates all remain, so a predicate that holds can never resurrect a button the user may not press. A bare boolean yields no predicate, which keeps the boolean form the host's channel rather than growing a second definition of it here.

Red-first

New file packages/plugin-detail/src/__tests__/DetailView.userActionPredicates.test.tsx, on the card's own fixture (showcase_invoice's real declaration, paid INV-1011 vs open INV-1001).

Reverse verification was done by removing the fix with git checkout origin/main -- DetailView.tsx and restoring from a sha256-verified copy (never git stash). Exactly the four new-behaviour cases move; the seven must-not-change cases are green in both directions.

Pre-fix (fix removed), verbatim — the failing element's opening tag is spelled with a space after the angle bracket, because GitHub strips a bare bracket-plus-letter from a stored body as an HTML tag and swallowed the line on the first write:

 × hides the header Delete on a record its `delete.visibleWhen` excludes 200ms
× renders the header Edit DISABLED when `edit.disabledWhen` holds 1034ms
× accepts the canonical `{ dialect, source }` envelope 62ms
× treats a literal `visibleWhen: false` as declared, not ungated 52ms
AssertionError: expected [ 'Share', 'Edit', 'Delete' ] to not include 'Delete'
Error: expect(element).toBeDisabled()
Received element is not disabled:
< button
class="… bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 gap-2 hidden sm:inline-flex"
data-state="closed"
/ >
Test Files 1 failed (1)
Tests 4 failed | 7 passed (11)

Post-fix:

 Test Files 1 passed (1)
Tests 11 passed (11)

Must-not-change, green on both sides: predicate-TRUE record keeps Delete and an enabled Edit; an object with no userActions is completely ungated; delete: false yields no predicate (the boolean stays the host's channel) while the host lowering it to showDelete: false still hides Delete; a read-only role keeps Delete hidden even though the predicate holds; sys_share is untouched on an excluded record.

Verification

pnpm --filter '@object-ui/plugin-detail^...' build exit=0 (dependency closure first)
pnpm exec vitest run packages/plugin-detail/ 81 files, 781 tests passed
pnpm exec tsc --noEmit (plugin-detail) exit=0
pnpm exec tsc -p tsconfig.test.json (plugin-detail) exit=0
pnpm --filter @object-ui/plugin-detail lint exit=0 (0 errors; pre-existing warnings)
node scripts/check-control-bytes.mjs OK (4221 files)
node scripts/check-changeset-presence.mjs OK
node scripts/check-changeset-no-major.mjs OK

.d.ts measured both ways — dist/index.d.ts built from origin/main and from this branch is byte-identical (78 lines, diff empty), so no public type surface moves. Changeset: patch@object-ui/plugin-detail.

Scope, and one thing the PM should read

Surface is DetailView.tsx + its new test + one changeset. Untouched by ruling: RelatedList.tsx (already correct, read-only reference), plugin-grid/**, selection-bar components (#4420), console/**, content/docs/releases/.

Measured, and it does not match the card's repro attribution: the console record page's header is not served by DetailView. plugin-detail/src/renderers/record-details.tsx:257 mounts the inner detail-view with showHeader: schema.showHeader ?? false, and app-shell/src/views/RecordDetailView.tsx has zero DetailView mounts — its header Edit/Share/Delete come from synthSystemActions at line 1911, which ANDs resolveRecordHeaderActionGates(objectDef, …) (the boolean affordance channel) with the record-level verdict and never parses or evaluates the predicate.

So this PR fixes the DetailView header — RecordDetailDrawer and every host mounting detail-view / detail with showEdit / showDelete — which is exactly the site #4419 names and the ruling scoped. It does not close the console page-header path that #4419's browser table screenshotted; that surface is already owned by the open #4213, where the file:line evidence has been posted as a comment rather than as a duplicate issue. No new issue filed.


Generated by Claude Code

…cates (#4419)
`userActions.delete` reached the record detail header in its BOOLEAN form but
not in its PREDICATE form. `delete: false` removed Delete from the row kebab,
the selection bar and the detail header, because the host lowers the boolean
into `schema.showDelete`. `delete: { visibleWhen: … }` reached only the row
kebab: the header ANDed `schema.showDelete ∧ objectAllowsDelete ∧
canDeleteRecord` and never evaluated the predicate, so an author upgrading
from "nobody may delete this object" to "these records may not be deleted"
silently lost the surface they were most likely to have tested on
(ADR-0078: no silently-inert metadata).
The header now folds the per-record predicates in as a FOURTH conjunct,
evaluated against the open record through the same helper family the row
surfaces already use — no fresh predicate-evaluation code on this path:
* `userActionPredicates` from `@object-ui/core` for the parse — the exact
import `RelatedList` makes, and the one `plugin-grid`'s
`resolveRowCrudAffordances` feeds the row kebab from.
* `useRowPredicate` from `@object-ui/react` for the evaluation.
`plugin-grid`'s `evalRowActionVisibility` (the body of
`isBuiltinRowActionVisible`) documents itself as mirroring
`useRowPredicate(pred, row, { fallback: false, warnOnError: true, label,
fields })` exactly, boolean short-circuit included, and is hook-free only
because a row loop evaluates a variable number of actions inside one
`useMemo`. The header evaluates a fixed arity of one record.
`visibleWhen` hides (fails closed, declared by `!= null` so `visibleWhen:
false` is a gate rather than "ungated"); `disabledWhen` greys the affordance
instead of removing it, on the desktop CTA and its mobile overflow twin alike
— the kebab's rule for the same key. `objectSchema.fields` is passed to every
predicate for the same reason `ObjectGrid` passes `objectFields` to
`RowActionMenu`.
The permission and record-writability gates are unchanged, so a predicate that
holds can never resurrect a button the user may not press; a bare boolean
yields no predicate, keeping the boolean form the host's channel alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 13, 2026 3:10am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-CNHFn00c.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)38.46KB10.17KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.32KB108.45KB
core (index.js)3.37KB1.34KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)153.79KB41.35KB
fields (index.js)230.07KB57.07KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.86KB12.91KB
plugin-charts (index.js)62.07KB17.65KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)120.95KB31.53KB
plugin-designer (index.js)212.58KB42.83KB
plugin-detail (index.js)239.88KB59.99KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)188.40KB50.10KB
plugin-kanban (index.js)48.62KB13.42KB
plugin-list (index.js)111.13KB27.12KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.16KB10.96KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.08KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.73KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude