fix(app-shell): the console record header honors userActions predicates (#4213) - #4524
Merged
Merged
Conversation
…es (#4213) `userActions.edit` / `.delete` reached the console record page's header in their boolean form but not in their predicate form. The boolean switch flows through `resolveRecordHeaderActionGates` -> `resolveEffectiveCrudAffordances`, so `userActions: { delete: false }` hid Delete on the list row and the record header alike. The per-record object form — `edit: { visibleWhen: ... }` — reached the list row only: `synthSystemActions` gated on `objectAffordances.edit && recordWriteAllowed` and never parsed, let alone evaluated, the predicate. Fold the predicates in as a fourth conjunct, through the same helper family the row surfaces use — `userActionPredicates` from `@object-ui/core` for the parse, `useRowPredicate` from `@object-ui/react` for the evaluation. The hooks live beside the other record-level verdicts because `synthSystemActions` is a plain IIFE sitting after the component's early returns; their results are threaded in as ordinary values. `visibleWhen` false hides the synthesized action (fails closed; a literal `false` counts as a declared gate). `disabledWhen` true composes with OR onto the `disabled` key `sys_edit` already used for the approval lock, and spreads a fresh one onto `sys_delete` only when it holds. The record body's inline-edit gate joins the same conjunction where `approvalLocked` already sits, so the header CTA and the body pencils cannot disagree. The affordance, permission and record-writability gates are untouched — the predicate only ever subtracts. 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 04:17
Collaborator
Author
|
PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)
Auto-merge armed (squash). Generated by Claude Code Generated by Claude Code |
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 free
to 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 #4213
The asymmetry
userActions.edit/.deletereached the console record page's header in theirboolean form and only in that form. The boolean switch flows through
resolveRecordHeaderActionGates→resolveEffectiveCrudAffordances, souserActions: { delete: false }hid Delete on the list row and on the recordheader — which is exactly the 定位线索 the card's author reported. The per-record
object form (
edit: { visibleWhen: … }, objectui#2614) reached the list rowalone:
synthSystemActionsatRecordDetailView.tsx:1911gated on— the affordance/permission channel — and nothing on that path ever parsed a
predicate, let alone evaluated one. An author narrowing "who may edit this
object" to "which records may be edited" therefore kept the converged list and
lost the record page, where Edit still opened a form the server rejects on save.
Three surfaces, one evaluator
This is the last of three sites, and it closes the convergence:
plugin-gridisBuiltinRowActionVisible(#2614)DetailViewheaderplugin-detail(#4419 / PR #4515)app-shellRecordDetailView.synthSystemActionsSame helper family as the other two, so one authored predicate gets one answer
platform-wide:
userActionPredicatesfrom@object-ui/corefor the parse, anduseRowPredicatefrom@object-ui/reactfor the evaluation, with{ fallback: false, warnOnError: true, label, fields }.Fold site and the hook-rules answer.
synthSystemActionsis a plain IIFE inthe component body, sitting after the
isLoading/!objectDef/pageRecordStatus === 'missing'early returns — hooks cannot live inside it. Sothe parse and the four evaluations sit at component level beside
recordWriteAllowed/recordDeleteAllowed, the record-level verdicts theyjoin, and their results are threaded into the IIFE as ordinary values. That is
the same shape PR #4515 used.
Semantics
visibleWhenfalse ⇒ the synthesized action is not emitted. Fails closed,and counts as declared by
!= nullrather than by truthiness, so a literalvisibleWhen: falsehides rather than reading as ungated (the objectui#3492invariant). The canonical
{ dialect, source }envelope is accepted.disabledWhentrue ⇒ the entry renders disabled. Onsys_editthiscomposes with OR onto the
disabledkey the approval lock already used(framework#3794) rather than opening a second one: an approval lock and a
declared predicate are independent reasons for the same off, and neither may
cancel the other. On
sys_delete— which declared nodisabledkey before —it is spread only when it holds, so the emitted ActionDef is byte-identical
when no predicate is declared. Fails soft, with the
!= nullgate outside theevaluation so
disabledWhen: ''reads as "no condition".predicate only ever subtracts: it can never resurrect a button the lifecycle
bucket closed or the user may not press.
Red-first
New file
RecordDetailView.userActionPredicates.test.tsxmounts the realconsole record page (
MemoryRouter+MetadataCtx, the harnessRecordDetailView.headerRefresh.test.tsxestablished) and asserts on real headerDOM. Reverse-verified by taking the fix back out with a sha256-verified patch —
never
git stash:The card's own repro, verbatim, on a
status: 'reported'record whoseedit.visibleWhenisrecord.status == "pending" || record.status == "in_progress":The 10 that go red without the fix: both
visibleWhenhides (edit, delete), bothdisabledWhengreys, the literalvisibleWhen: falsegate, the{ dialect, source }envelope, the fail-closed fault, the CRUD-only subtraction,and two of the approvalLocked composition cases.
Must-not-change — green on both sides (the 11)
delete: false/edit: falsestill hide (the card's 定位线索 — theaffordance resolver stays their only channel; a bare boolean yields no
predicate);
userActionsis completely ungated;engine-ownedbucket still closes both, however loudly the predicate holds;sys_shareand other non-CRUD synth actions untouched;approvalLockeddisables Edit with no predicate declared, and still does whena declared predicate does not hold — the pre-[console] 记录详情页头不消费 userActions.<action>.visibleWhen:同一谓词列表行已生效、记录页头照旧渲染按钮 #4213 behavior byte-identical;
disabledWhenfails soft on a fault, and an emptydisabledWhenis nocondition.
Verification
npx vitest run packages/app-shell→ 366 files, 3542 passed, 1 skipped, 0 failednpx tsc --noEmitandnpx tsc -p tsconfig.test.json(app-shell) → both exit 0RecordDetailView.tsxis 116 before, 116 after (zero new findings)
node scripts/check-control-bytes.mjs→ OK, plus a direct control-byteself-scan of all three changed files → clean
.d.tsmeasured both ways: built app-shell with and without the fix anddiffed the emitted
dist— 0.d.tsdifferences across 415 declarationfiles. No public type surface moves, which is what makes this a
patch.Scope
packages/app-shell/src/views/RecordDetailView.tsx+ one test file + onechangeset. Untouched: selection bar (#4420),
DetailView/RelatedList(landedin #4515),
console/AppContent.tsx(#4252), metadata-admin (#4446),content/docs/releases/.One extension beyond the ruling's literal text, reported rather than made
silently. The ruling named
synthSystemActions. TheInlineEditProvidercanEditexpression at the former:2082mirrors that same gate conjunction(
resolveRecordHeaderActionGates(...).edit && recordWriteAllowed && !approvalLocked) because the header CTA and the record body's pencils are oneedit affordance in two places. Folding the predicate into only one of them would
have re-created, inside a single file, precisely the asymmetry this card is
about — Edit gone from the header while a double-click still opened a draft the
object says this record may not have. The predicate therefore joins that
conjunction too, exactly where
approvalLockedalready sits, anddisabledWhensuppresses the pencils for the same reason the approval lock does.
Generated by Claude Code