Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-detail): the record detail header honors userActions predicates (#4419) - #4515
Merged
Merged
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 13, 2026 03:22
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes#4419
The asymmetry
userActions.deletereached the record detail header in its boolean form but not in its predicate form.userActions: { delete: false }schema.showDeleteuserActions: { delete: { visibleWhen: … } }The header ANDed three gates and never evaluated the predicate (
DetailView.tsx:306-307onmain):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:
userActionPredicatesfrom@object-ui/corefor the parse — the exact importRelatedList.tsx:1130-1131makes, and the oneplugin-grid'sresolveRowCrudAffordancesfeeds the row kebab from. It is THE parser for the boolean/object form.useRowPredicatefrom@object-ui/reactfor the evaluation.plugin-grid'sevalRowActionVisibility— the body ofisBuiltinRowActionVisible, whichRowActionMenu.tsx:348-350calls — documents itself as mirroringuseRowPredicate(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 oneuseMemo. The header evaluates a fixed arity of one record, so the hook form is that same evaluator without the constraint that shaped the wrapper. (isBuiltinRowActionVisibleitself is not importable here:plugin-gridis not aplugin-detaildependency, and@object-ui/components' copy is not re-exported from its barrel.)Posture copied verbatim from
DataTableBuiltinRowActionItem:visibleWhenfalse → the header affordance is hidden. Fails CLOSED; declared by!= null, not truthiness, sovisibleWhen: falseis a gate rather than "ungated" (the objectui#3492 invariant).disabledWhentrue → the affordance renders disabled, not removed — kebab symmetry. Applied to the desktop Edit CTA and itssm:hiddenoverflow twin, plus the overflow Delete, so one predicate gets one answer at every breakpoint.objectSchema.fieldsis passed to every predicate for the same reasonObjectGridpassesobjectFieldstoRowActionMenu: a relation field must bind as the stored foreign key, not whatever$expandsubstituted 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, paidINV-1011vs openINV-1001).Reverse verification was done by removing the fix with
git checkout origin/main -- DetailView.tsxand restoring from a sha256-verified copy (nevergit 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:
Post-fix:
Must-not-change, green on both sides: predicate-TRUE record keeps Delete and an enabled Edit; an object with no
userActionsis completely ungated;delete: falseyields no predicate (the boolean stays the host's channel) while the host lowering it toshowDelete: falsestill hides Delete; a read-only role keeps Delete hidden even though the predicate holds;sys_shareis untouched on an excluded record.Verification
.d.tsmeasured both ways —dist/index.d.tsbuilt fromorigin/mainand from this branch is byte-identical (78 lines,diffempty), 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:257mounts the innerdetail-viewwithshowHeader: schema.showHeader ?? false, andapp-shell/src/views/RecordDetailView.tsxhas zeroDetailViewmounts — its header Edit/Share/Delete come fromsynthSystemActionsat line 1911, which ANDsresolveRecordHeaderActionGates(objectDef, …)(the boolean affordance channel) with the record-level verdict and never parses or evaluates the predicate.So this PR fixes the
DetailViewheader —RecordDetailDrawerand every host mountingdetail-view/detailwithshowEdit/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