Skip to content

fix(app-shell): run sys_approval_request's declared decision actions on the record page (#3055) - #4077

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3055-record-approval-actions
Aug 10, 2026
Merged

fix(app-shell): run sys_approval_request's declared decision actions on the record page (#3055)#4077
yinlianghui merged 2 commits into
mainfrom
claude/issue-3055-record-approval-actions

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3055

A business record with an approval pending on it offered exactly two buttons — Approve and Reject — hand-written into the record header behind a bespoke type:'approval' handler and a client-side approver test. The approvals list, over the same request and the same nine REST routes, offered five decisions plus the submitter's levers and took decision attachments. On a business record, reassign / send back / request info had no entry point at all, a decision could not carry a file, and the copy on the two surfaces was maintained separately.

The record page now renders sys_approval_request's own declared actions through DeclaredActionsBar — the same metadata, the same action runtime, the same param dialogs the approvals list uses. Adding a tenth decision action is a metadata change with no console work.

The card's three steps

step state
1. fetch the pending sys_approval_request with the server viewer block already shippeduseRecordApprovals enriches the pending row via getRequest, which is what attaches viewer. The block was fetched and then ignored.
2. render the same declared server actions on the record page this PR
3. retire useRecordApprovals' decide / canDecide, keep status badge + lock_record this PR

What changed

  • RecordDetailView — the hard-coded injection and buildApprovalDecisionActions are gone, and with them the type:'approval' handler. A DeclaredActionsBar renders above the page body when a request is pending, with the request row as the dispatch record.
  • useRecordApprovals — read half only. canDecide / approve / reject and the currentUserId parameter are retired.
  • DeclaredActionsBar — the predicate scope now binds the row the three ways the record header and list rows bind it. See below; this is the load-bearing part.
  • recordApprovalActions.ts — the three coordinates of the wiring (object, location, the one excluded action), stated once.

Why the header could not carry these actions

action:bar evaluates a visible predicate against this page's record and stamps that same record into params._rowRecord (containers.tsx:1278-1300). A business record has no viewer block, and a declared action targeting /api/v1/approvals/requests/{id}/… would resolve {id} to the business record's id. The request row has to be the dispatch record, which is what the bar does — so the card's suggested record_section → header/overflow mapping is not needed: the page consumes the declared record_section location verbatim.

The declared actions were not rendering anywhere

The bar passed the row in as the bare predicate scope, so only the shorthand spelling (status == "pending") resolved. The canonical spelling is the record. root — it is what ExpressionEvaluator's CEL path binds, what evalRowPredicate binds on the record header and list rows, and what the server enforces with. Under a root-only bag record.viewer.can_act does not read as false; it raises record is not defined, and the fail-closed gate turns that into "hidden".

Every declared action on sys_approval_request gates on record.viewer.*, so the whole server-declared decision set was invisible on every surface this bar renders — the approvals inbox included. Measured against the shipped evaluator:

ROOT-CONTEXT    => threw: true,  'record is not defined'
WITH-RECORD-KEY => threw: false, true

The row now binds three ways (record.status, bare status, data.status), so both spellings reach a verdict. The inbox page itself is untouched; it gains the decision levers it was already written to expect (its "why disabled" copy and its "no longer hand-wired here" comments both assume them).

An acceptance-line correction, recorded rather than worked around

The triage acceptance line reads: a group approver (position:xxx-style entry in pending_approvers) sees and can execute the same five decision actions. The first half is delivered; the parenthetical describes a state the platform treats as routed to nobody, and no consumer-side change reaches it:

  • a staffed position / team / department is resolved to concrete user ids at request-open time (approval-service.ts:920-932, the position branch at :928), so viewer.can_act is true and all five actions render — this is the acceptance case, and it is covered by tests;
  • a bare position:xxx literal survives in pending_approvers only when the graph lookup produced nobody (:969). The service logs it as a routing bug ("the slot routes to no one and the request cannot advance", framework#3807) and can_override (framework#3424) is the designed recovery — the admin rescue path, which the record page previously had zero entry to and now has.

The card's stated root cause — that the record page's client-side includes fails where server viewer.can_act succeeds — is therefore only half right: attachViewers computes can_act with the same membership test on the same array (approval-service.ts:4017). What actually changes for gating is that override admins and submitters now reach the record page, and that the record page stops holding a second opinion at all. The five-action gap, the missing attachments and the forked copy — the bulk of the card — were real and are fixed.

Tests

New RecordDetailView.approvalDeclaredActions.test.tsx mounts the real record page against a pending request and asserts the surface an approver actually gets: all five decisions present for can_act, the three that had no entry point specifically, submitter levers hidden, the POST landing on /approvals/requests/req_qif_1/revise (not on the business record id), the dotted outputs. param folded into the nested body, override-admin and submitter matrices, and fail-closed on a backend with no viewer block.

Reverse verification: reverting only the predicate binding turns 9 of the 13 red — the four that survive are the two metadata-shape assertions and the two absence cases, which is the expected direction for a change whose whole effect is "the gate can now be evaluated".

Retired with their subject: RecordDetailView.approvalDecisionActions.test.tsx (pinned the deleted builder) and useRecordApprovals.decisionOutputs.test.tsx (pinned the deleted approve() body — its concern, the nested outputs, is re-asserted end-to-end in the new file).

vitest run packages/app-shell   → 313 files, 2928 passed | 1 skipped
vitest run apps/console         →  29 files,  300 passed
pnpm --filter @object-ui/app-shell type-check → clean
pnpm --filter @object-ui/app-shell lint       → 0 errors

Out-of-scope findings

origin/main merged before opening (carries #4068 / #4070 / #4071 / #4072); no file overlap with #4071's approvals-inbox component ref.


Generated by Claude Code

claude added 2 commits August 10, 2026 04:27
…on the record page (#3055)

A business record with an approval pending on it offered exactly two
buttons — Approve and Reject — hand-written into the record header behind
a bespoke `type:'approval'` handler and a client-side approver test. The
approvals list, over the same request and the same nine REST routes,
offered five decisions plus the submitter's levers and took decision
attachments. Reassign / send back / request info had NO entry point on a
record, a decision could not carry a file, and the copy was forked.

The record page now renders `sys_approval_request`'s own declared actions
through DeclaredActionsBar: same metadata, same action runtime, same
param dialogs as the approvals list. The dispatch record is the pending
REQUEST row, which is what resolves `{id}` and what gives each action's
`visible` the server-computed `viewer` block to read.

- `useRecordApprovals` keeps only its read half. `canDecide` / `approve` /
  `reject` and the `currentUserId` parameter are retired: who may act is
  the server's answer on the row, and deciding is the declared action's
  POST.
- The bar's predicate scope now binds the row the three ways the record
  header and list rows bind it (`record.status`, bare `status`,
  `data.status`). It bound only the bare form, so `record.viewer.can_act`
  threw `record is not defined` and the fail-closed gate hid it — which
  made EVERY declared action on `sys_approval_request` invisible on every
  surface the bar renders, the approvals inbox included.
- `useMetadataItem` is imported from `@object-ui/react` rather than
  through `../providers/MetadataProvider`, whose module graph builds an
  authenticated fetch at import time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 4:36am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-H2nurVj-.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 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) 483.91KB 106.75KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 228.51KB 56.69KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.84KB 10.80KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.49KB 17.48KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.50KB 30.66KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 113.81KB 27.53KB
plugin-gantt (index.js) 162.79KB 39.67KB
plugin-grid (index.js) 187.97KB 49.79KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.96KB 26.64KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 04:44
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 99782f9 Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3055-record-approval-actions branch August 10, 2026 04:45
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 10, 2026
…objectstack-ai#4075) (objectstack-ai#4079)

`action:button` / `action:icon` / `action:menu` / `action:group` gated their
actions on `useCondition(pred, ctx)` with the row spread flat — or, on three
of the leaves, with no row at all. Only the bare-field shorthand resolved, so
the CANONICAL `record.` root threw `record is not defined`; a fail-closed
`visible` turned that into "hidden" and a correctly-authored predicate deleted
its own button. Every declared action on `sys_approval_request` gates on
`record.viewer.*`, which is how the same binding suppressed the whole
server-declared approval decision set until objectstack-ai#4077 fixed the declared-action bar.

All four now bind `record.status` / bare `status` / `data.status` through one
named helper (`usePredicateRecordContext`, beside `useCondition` in
`@object-ui/react`) — `evalRowPredicate`'s rule, restated for this tier.
`action:icon` reads its `data` prop at all; the menu/group leaves receive the
row from their host; `action:bar` forwards the row into the overflow menu it
builds, so a predicate no longer answers a different question because its
action spilled past `maxVisible`.

The evaluation entry and every site's error policy are untouched: a genuinely
faulting predicate still fails closed where it did and soft where it did. A
surface with no row binds nothing rather than an empty record, so a host that
supplies the row through the ambient scope is not blanked out.


Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3

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

Labels

Projects

None yet

2 participants