Skip to content

Commit 054652b

Browse files
fix(app-shell,react,plugin-detail): fold an override-only admin's approval levers into one menu and draw Recall once (#26053)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Yb7oXSyx8Wfrc3ohS5yGJm)_ Fixes #25877 Clause-②: yes (widening) Metadata-protocol: none The `Clause-②` arm is the claim's ruling: one optional member is added to the exported `InlineEditContext` value (and to `InlineEditProvider`'s props) of `@object-ui/react`, which widens its public type surface and narrows nothing. The member is optional so that every existing host keeps its behaviour: absent means `false`, and then the DetailView approval band draws its own Recall exactly as before. A host sets it only when it draws the request's declared Recall itself. ## What changes Placement only. Who may override, what an override records, the override notice each lever shows and the request each lever sends are unchanged. - **Record page, admin who is not the approver** (`viewer.can_act: false`, `viewer.can_override: true`): the approval bar above the title is now one line: a Pending badge with "Waiting on" and the pending approvers by name, the levers with no override arm as buttons (Reply, for the submitter), and one amber "Admin override" menu holding every lever the override flag admits (Override Approve, Override Reject, Override Reassign, and Recall, whose gate ORs the flag in too). The folded set is read off each action's own `visible` gate (`actionAdmittedByOverride`), not off a name list. The submitter's own Recall stays the plain Recall inside the menu; a non-submitter admin sees Override Recall, as before. - **Record page, approver**: unchanged (`foldOverrides` folds nothing for a viewer who can act). - **Recall drawn once**: `DeclaredActionsBar` reports which declared actions it actually draws (new optional `onDrawnActionsChange`, after the capability gate and each action's `visible` verdict; folded levers report through a visibility probe while their menu is closed). While it draws `approval_recall`, the record page sets `approvalRecallDrawnByHost`, and the DetailView approval band (`@object-ui/plugin-detail`) keeps its lock badge and tally only. Read off what the bar draws, not off whether it is mounted: before the request definition loads, or on a backend that sends no `viewer` block (every declared gate fails closed), the bar draws no Recall and the band stays the submitter's lever. - The override menu appears only once a folded lever is shown, so a set whose every gate says no leaves no empty menu. - `scripts/sdui-manifest.record.json` re-recorded on the merged tree (`node scripts/gen-sdui-manifest-node.mjs`); `sdui.manifest.json` itself is byte-identical. Files: `ui/packages/app-shell/src/views/DeclaredActionsBar.tsx`, `ui/packages/app-shell/src/views/RecordDetailView.tsx`, `ui/packages/react/src/context/InlineEditContext.tsx`, `ui/packages/plugin-detail/src/DetailView.tsx` (seat 2's package, held by this card with seat 2's knowledge), tests beside each, root changeset (patch for `@object-ui/app-shell`, `@object-ui/react`, `@object-ui/plugin-detail`). ## Verification All on head `df86fb71` (main `39a78b15` merged in): ``` $ cd ui && pnpm exec vitest run --maxWorkers=2 PATHS # 101 files under the verify lock Test Files 101 passed (101) Tests 728 passed (728) $ pnpm --filter @object-ui/react --filter @object-ui/plugin-detail --filter @object-ui/app-shell type-check os-verify-lock: VERDICT command-exit 0 ``` The 101 files are every `RecordDetailView.*`, `RecordApprovalsPanel.*` and `DeclaredActionsBar.*` test in app-shell, every `DetailView.*` test in plugin-detail, and the `InlineEditContext` / `resolveApprovalLock` tests in react. They include `RecordDetailView.recordOpenRequests-12701`, whose control case ("every approvals consumer still shows its state") is what moved the signal from "bar mounted" to "bar draws Recall". New tests: - `app-shell/src/views/__tests__/DeclaredActionsBar.foldOverrides-25877.test.tsx` (8): the fold, the status line, Reply stays a button, menu order and labels, a folded lever still dispatches with its override notice, the approver's bar unchanged, no change without `foldOverrides`, the drawn-actions report (menu closed, approver set, unmount), no empty menu. - `plugin-detail/src/__tests__/DetailView.approvalRecallHostDrawn-25877.test.tsx` (3): signal on, absent, and toggled. - `RecordDetailView.approvalRecallGate.test.tsx` (+3, page to band seam): the signal is true while the bar draws Recall, false on a backend with no `viewer` block, false with no approvals API. - `RecordDetailView.approvalDeclaredActions.test.tsx`: the override-admin case now opens the menu to find its three levers. The first type-check run on the merged tree failed once in `metadata-admin/useMonacoFallback.ts` (`configureBundledMonaco` missing from a `plugin-editor` dist built before #25993 merged); after `pnpm --filter '@object-ui/app-shell^...' build` all three packages pass. That file is not in this diff. Reverse verification, each mutation through `scripts/ablation-replace.mjs` with its on-disk proof and a byte-identical restore to `HEAD` (tests resolve `@object-ui/*` to source, so no rebuild leg): | mutation | went red | |---|---| | band: drop the `approvalRecallDrawnByHost` term from the Recall condition | 2 of 3 in `DetailView.approvalRecallHostDrawn-25877` | | bar: never fold (`false && foldOverrides …`) | 3 of 8 in `DeclaredActionsBar.foldOverrides-25877` | | bar: always draw the menu trigger | "draws no menu when every folded lever is hidden by its own gate" | | page: `approvalRecallDrawnByHost={false}` | "tells the band the page draws recall while the decision bar draws it" | | page: signal on mount instead of on drawn (`&&` to `\|\|`) | "leaves the band its recall when the bar is up but draws none" | Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 46 commands for this diff (10 paths vs merge base `39a78b15`); every one exited 0 on the merged tree, including `check-sdui-manifest.mjs` after the re-record. `--ran` reconciliation: "46 derived famil(ies) accounted for — 46 run, 0 NOT-MEASURED". After the last commit (changeset wording only) the changeset-reading gates were re-run and exited 0. ## Acceptance notes - **Placement reading.** The card says 「标题旁一个「…」菜单」. The menu sits where the bar was, on the line above the title, and is not injected into `page:header`; the seat's claim records this reading. - **Status wording.** The line reuses existing bundle keys (`approvalsInbox.statusPending`, `approvalsInbox.waitingOn`, `approvalsInbox.approverNameSeparator`, `approvalsInbox.viaOverrideChip` for the menu's name), so in zh-CN it reads 「待审批」 + 「等待以下审批人」 + names rather than the card's literal 「审批中 · 等待 X」. A dedicated sentence key would touch `ui/packages/i18n` in ten locales, outside this card's file surface. carrier: none. - **Menu items keep the menu open while the lever's own dialog runs** (`preventDefault` on select), the same shape as the page header's overflow items in `PageHeaderActions`. - **Narrowed local run.** `ui/packages/app-shell` has about 1700 test files; locally only the files named after the touched components ran (listed under Verification). The full package is CI `Test Core`'s. --- _Generated by [Claude Code](https://claude.ai/code/session_014N3u93mMkVGhq6DBKXqx4J)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1aef17 commit 054652b

11 files changed

Lines changed: 907 additions & 43 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/react': patch
4+
'@object-ui/plugin-detail': patch
5+
---
6+
7+
fix(app-shell,react,plugin-detail): on a record under approval, an administrator who is not the approver sees one status line and one override menu instead of five buttons above the title, and Recall is drawn once
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/app-shell`:** for a viewer who holds no approver slot but may override, the record page's approval bar is now one line: the request's pending status and who the step waits on, the levers that have no override arm (Reply) as buttons, and one marked menu holding every lever the override flag admits (Override Approve, Override Reject, Override Reassign, and Recall). The approver's own bar is unchanged. Who may override, the override notice each lever shows, and the request each lever sends are unchanged. `DeclaredActionsBar` takes a new optional `foldOverrides` prop for this; a host that does not pass it renders as before.
13+
- **`@object-ui/react`:** `InlineEditProvider` and the `InlineEditContext` value gain an optional `approvalRecallDrawnByHost`. It is optional so every existing host keeps its behaviour: absent means `false`, and then the approval band draws its own Recall exactly as before. A host that draws the pending request's declared Recall itself sets it to `true`.
14+
- **`@object-ui/plugin-detail`:** the DetailView approval band leaves out its Recall button when the host says it draws one, and keeps its lock badge and tally. The record page sets the signal only while its approval bar actually draws the request's declared Recall, so Recall appears once on the page; before the bar can draw it (definition still loading, or a backend that sends no viewer block) the band keeps its own.
15+
16+
What to do: nothing.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"c13b11a725295ab5e510c572d774271e3ec9a7120442907249bace960974f9bd","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/react","sources":70,"sha256":"1d1036b80f21e46d0b90bb61ba8cb285336f396ac85ce66daa42073e8682c3e2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/app-shell/src/views/DeclaredActionsBar.tsx‎

Lines changed: 335 additions & 31 deletions
Large diffs are not rendered by default.

‎ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,9 +471,15 @@ describe('record page decision actions — gating is the SERVER\'s (objectui#305
471471
stubApprovalsApi(pendingRequest({ can_act: false, is_submitter: false, can_override: true }));
472472
renderRecordPage();
473473

474-
await waitFor(() => expect(decisionButton('approval_approve')).toBeTruthy());
475-
expect(decisionButton('approval_reject')).toBeTruthy();
476-
expect(decisionButton('approval_reassign')).toBeTruthy();
474+
// objectstack#25877 — for an admin who holds no slot the record page folds
475+
// the rescue levers into one marked menu, so they are read from there.
476+
const trigger = await screen.findByTestId('declared-actions-override-menu');
477+
expect(decisionButton('approval_approve'), 'folded, not a button').toBeNull();
478+
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' });
479+
await screen.findByRole('menu');
480+
expect(decisionButton('approval_approve')?.getAttribute('role')).toBe('menuitem');
481+
expect(decisionButton('approval_reject')?.getAttribute('role')).toBe('menuitem');
482+
expect(decisionButton('approval_reassign')?.getAttribute('role')).toBe('menuitem');
477483
expect(decisionButton('approval_send_back')).toBeNull();
478484
expect(decisionButton('approval_request_info')).toBeNull();
479485
});

‎ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ vi.mock('@object-ui/react', async (importOriginal) => {
9191
data-testid="inline-probe"
9292
data-approval-is-submitter={String(inline?.approvalIsSubmitter)}
9393
data-approval-pending={String(inline?.approvalPending)}
94+
data-approval-recall-drawn-by-host={String(inline?.approvalRecallDrawnByHost)}
9495
/>
9596
);
9697
};
@@ -158,15 +159,37 @@ function stubApprovalsApi(row: Record<string, unknown> | null, listStatus = 200)
158159
vi.stubGlobal('fetch', approvalsFetch);
159160
}
160161

162+
/**
163+
* The request object's declared recall, so the page's decision bar can draw it
164+
* (objectstack#25877). The gate is the shipped one in the reduced spelling the
165+
* sibling approval suites use: the submitter arm OR the override arm.
166+
*/
167+
const SYS_APPROVAL_REQUEST_DEF = {
168+
name: 'sys_approval_request',
169+
label: 'Approval Request',
170+
fields: { id: { type: 'text', label: 'Id' }, status: { type: 'text', label: 'Status' } },
171+
actions: [
172+
{
173+
name: 'approval_recall', label: 'Recall', type: 'api', method: 'POST',
174+
target: '/api/v1/approvals/requests/{id}/recall',
175+
params: [{ name: 'comment', label: 'Comment', type: 'textarea', required: false }],
176+
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter'
177+
+ ' || record.viewer.can_override',
178+
locations: ['record_section'],
179+
},
180+
],
181+
};
182+
161183
const METADATA = {
162-
objects: OBJECTS,
184+
objects: [...OBJECTS, SYS_APPROVAL_REQUEST_DEF],
163185
pages: [],
164186
loading: false,
165187
error: null,
166188
refresh: async () => {},
167189
invalidate: () => {},
168190
ensureType: async () => [],
169-
getItem: async () => null,
191+
getItem: async (type: string, name: string) =>
192+
(type === 'object' && name === SYS_APPROVAL_REQUEST_DEF.name ? SYS_APPROVAL_REQUEST_DEF : null),
170193
getItemsByType: () => [],
171194
} as any;
172195

@@ -286,3 +309,44 @@ describe('record page → band: who may see recall (objectui#6464)', () => {
286309
expect(probe.getAttribute('data-approval-is-submitter')).toBe('undefined');
287310
});
288311
});
312+
313+
/**
314+
* Recall is drawn ONCE per page (objectstack#25877). The page's declared
315+
* decision bar carries `approval_recall`, so whenever the page mounts that bar
316+
* it must tell the band below to keep its lock badge only, and whenever it
317+
* does not, the band stays the recall lever. `DetailView.approvalRecallHostDrawn`
318+
* measures the band; this measures the seam.
319+
*/
320+
describe('record page → band: who draws recall (objectstack#25877)', () => {
321+
it('tells the band the page draws recall while the decision bar draws it', async () => {
322+
stubApprovalsApi(pendingRequest({ viewer: { can_act: false, is_submitter: true, can_override: false } }));
323+
renderRecordPage();
324+
325+
const probe = await settledProbe(true);
326+
await waitFor(() =>
327+
expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('true'),
328+
);
329+
expect(screen.getByTestId('declared-action-approval_recall')).toBeInTheDocument();
330+
});
331+
332+
it('leaves the band its recall when the bar is up but draws none', async () => {
333+
// A server too old to send a `viewer` block: every declared gate fails
334+
// closed, so the bar draws no Recall, and the band, which falls back to
335+
// comparing `submitter_id`, must stay the submitter's lever.
336+
stubApprovalsApi(pendingRequest({ submitter_id: SIGNED_IN_USER }));
337+
renderRecordPage();
338+
339+
const probe = await settledProbe(true);
340+
await waitFor(() => expect(probe.getAttribute('data-approval-is-submitter')).toBe('true'));
341+
expect(screen.queryByTestId('declared-action-approval_recall')).toBeNull();
342+
expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('false');
343+
});
344+
345+
it('leaves the band as the recall lever when there is no approvals API', async () => {
346+
stubApprovalsApi(null, 404);
347+
renderRecordPage();
348+
349+
const probe = await settledProbe(true);
350+
expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('false');
351+
});
352+
});

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,12 @@ const COMMENT_REACTION_OBJECT = 'sys_comment_reaction';
383383
/** The object the record feed's comments and replies are rows of. */
384384
const COMMENT_OBJECT = 'sys_comment';
385385

386+
/**
387+
* `sys_approval_request`'s declared recall. While the decision bar draws it,
388+
* the DetailView approval band yields its own Recall (objectstack#25877).
389+
*/
390+
const APPROVAL_RECALL_ACTION = 'approval_recall';
391+
386392
/**
387393
* Which writes the record feed may offer: its comment and reply composers, the
388394
* author's Edit and Delete on their own comment (objectui#12268), and its
@@ -1683,6 +1689,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
16831689
// This gates the AFFORDANCE only. `canEdit` / `approvalLocked` below are
16841690
// untouched by it, and the recall endpoint authorizes the recall itself.
16851691
const approvalIsSubmitter = isSubmitterOf(approvals.pendingRequest, user?.id);
1692+
// Does this page mount the pending request's declared decision bar
1693+
// (objectui#3055)?
1694+
const approvalBarMounted = approvals.available && !!approvals.pendingRequest;
1695+
// Recall is drawn ONCE per page (objectstack#25877). The bar reports which
1696+
// declared actions it actually draws, after their capability and `visible`
1697+
// gates; while it draws the request's own `approval_recall`, the DetailView
1698+
// approval band keeps its lock badge only. Read off what the bar DRAWS, not
1699+
// off whether it is mounted: before the request's definition loads, or on a
1700+
// backend too old to send a `viewer` block (every declared gate fails
1701+
// closed), the bar draws no recall and the band stays the lever.
1702+
const [approvalBarDrawn, setApprovalBarDrawn] = useState<string[]>([]);
1703+
const approvalRecallDrawnByHost =
1704+
approvalBarMounted && approvalBarDrawn.includes(APPROVAL_RECALL_ACTION);
16861705

16871706
// A decision landed through the declared-action bar (objectui#3055). The
16881707
// action itself already POSTed and the runtime already toasted; what the HOST
@@ -3676,6 +3695,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
36763695
approvalReadAnswered={approvals.answered}
36773696
approvalProgress={approvalProgress}
36783697
approvalIsSubmitter={approvalIsSubmitter}
3698+
approvalRecallDrawnByHost={approvalRecallDrawnByHost}
36793699
lockedReason={t('detail.lockedTooltip', {
36803700
defaultValue: 'This record has a pending approval request; editing is locked',
36813701
})}
@@ -3808,15 +3828,26 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
38083828
renders a richer remind (throttle-aware copy, timeline
38093829
refresh), and `exclude` is how a host keeps one of an
38103830
object's declared actions in its own UI without the bar
3811-
double-rendering it. */}
3812-
{approvals.available && approvals.pendingRequest && (
3831+
double-rendering it.
3832+
3833+
objectstack#25877 — for an administrator who is NOT an
3834+
approver, the override levers (approve / reject / reassign
3835+
in their name, and recall) are the exception path, so
3836+
`foldOverrides` collapses them into one status line and one
3837+
marked menu; the approver's own bar is unchanged. While the
3838+
bar draws the request's Recall, it is the page's one Recall:
3839+
`onDrawnActionsChange` is how the page learns that and tells
3840+
the DetailView band below to keep its lock badge only. */}
3841+
{approvalBarMounted && (
38133842
<DeclaredActionsBar
38143843
className="mb-4"
38153844
objectName={SYS_APPROVAL_REQUEST_OBJECT}
38163845
record={approvals.pendingRequest}
38173846
location={RECORD_APPROVAL_ACTION_LOCATION}
38183847
exclude={[...RECORD_APPROVAL_EXCLUDED_ACTIONS]}
38193848
onDone={handleApprovalActionDone}
3849+
foldOverrides
3850+
onDrawnActionsChange={setApprovalBarDrawn}
38203851
/>
38213852
)}
38223853
<RelatedRecordActionsBridge

0 commit comments

Comments
 (0)