Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/25877-approval-override-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/react': patch
'@object-ui/plugin-detail': patch
---

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

Clause-②: yes (widening)
Metadata-protocol: none

- **`@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.
- **`@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`.
- **`@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.

What to do: nothing.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-detail.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"c13b11a725295ab5e510c572d774271e3ec9a7120442907249bace960974f9bd","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/react.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/react","sources":70,"sha256":"1d1036b80f21e46d0b90bb61ba8cb285336f396ac85ce66daa42073e8682c3e2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
366 changes: 335 additions & 31 deletions ui/packages/app-shell/src/views/DeclaredActionsBar.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,15 @@ describe('record page decision actions — gating is the SERVER\'s (objectui#305
stubApprovalsApi(pendingRequest({ can_act: false, is_submitter: false, can_override: true }));
renderRecordPage();

await waitFor(() => expect(decisionButton('approval_approve')).toBeTruthy());
expect(decisionButton('approval_reject')).toBeTruthy();
expect(decisionButton('approval_reassign')).toBeTruthy();
// objectstack#25877 — for an admin who holds no slot the record page folds
// the rescue levers into one marked menu, so they are read from there.
const trigger = await screen.findByTestId('declared-actions-override-menu');
expect(decisionButton('approval_approve'), 'folded, not a button').toBeNull();
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' });
await screen.findByRole('menu');
expect(decisionButton('approval_approve')?.getAttribute('role')).toBe('menuitem');
expect(decisionButton('approval_reject')?.getAttribute('role')).toBe('menuitem');
expect(decisionButton('approval_reassign')?.getAttribute('role')).toBe('menuitem');
expect(decisionButton('approval_send_back')).toBeNull();
expect(decisionButton('approval_request_info')).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ vi.mock('@object-ui/react', async (importOriginal) => {
data-testid="inline-probe"
data-approval-is-submitter={String(inline?.approvalIsSubmitter)}
data-approval-pending={String(inline?.approvalPending)}
data-approval-recall-drawn-by-host={String(inline?.approvalRecallDrawnByHost)}
/>
);
};
Expand Down Expand Up @@ -158,15 +159,37 @@ function stubApprovalsApi(row: Record<string, unknown> | null, listStatus = 200)
vi.stubGlobal('fetch', approvalsFetch);
}

/**
* The request object's declared recall, so the page's decision bar can draw it
* (objectstack#25877). The gate is the shipped one in the reduced spelling the
* sibling approval suites use: the submitter arm OR the override arm.
*/
const SYS_APPROVAL_REQUEST_DEF = {
name: 'sys_approval_request',
label: 'Approval Request',
fields: { id: { type: 'text', label: 'Id' }, status: { type: 'text', label: 'Status' } },
actions: [
{
name: 'approval_recall', label: 'Recall', type: 'api', method: 'POST',
target: '/api/v1/approvals/requests/{id}/recall',
params: [{ name: 'comment', label: 'Comment', type: 'textarea', required: false }],
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter'
+ ' || record.viewer.can_override',
locations: ['record_section'],
},
],
};

const METADATA = {
objects: OBJECTS,
objects: [...OBJECTS, SYS_APPROVAL_REQUEST_DEF],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItem: async (type: string, name: string) =>
(type === 'object' && name === SYS_APPROVAL_REQUEST_DEF.name ? SYS_APPROVAL_REQUEST_DEF : null),
getItemsByType: () => [],
} as any;

Expand Down Expand Up @@ -286,3 +309,44 @@ describe('record page → band: who may see recall (objectui#6464)', () => {
expect(probe.getAttribute('data-approval-is-submitter')).toBe('undefined');
});
});

/**
* Recall is drawn ONCE per page (objectstack#25877). The page's declared
* decision bar carries `approval_recall`, so whenever the page mounts that bar
* it must tell the band below to keep its lock badge only, and whenever it
* does not, the band stays the recall lever. `DetailView.approvalRecallHostDrawn`
* measures the band; this measures the seam.
*/
describe('record page → band: who draws recall (objectstack#25877)', () => {
it('tells the band the page draws recall while the decision bar draws it', async () => {
stubApprovalsApi(pendingRequest({ viewer: { can_act: false, is_submitter: true, can_override: false } }));
renderRecordPage();

const probe = await settledProbe(true);
await waitFor(() =>
expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('true'),
);
expect(screen.getByTestId('declared-action-approval_recall')).toBeInTheDocument();
});

it('leaves the band its recall when the bar is up but draws none', async () => {
// A server too old to send a `viewer` block: every declared gate fails
// closed, so the bar draws no Recall, and the band, which falls back to
// comparing `submitter_id`, must stay the submitter's lever.
stubApprovalsApi(pendingRequest({ submitter_id: SIGNED_IN_USER }));
renderRecordPage();

const probe = await settledProbe(true);
await waitFor(() => expect(probe.getAttribute('data-approval-is-submitter')).toBe('true'));
expect(screen.queryByTestId('declared-action-approval_recall')).toBeNull();
expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('false');
});

it('leaves the band as the recall lever when there is no approvals API', async () => {
stubApprovalsApi(null, 404);
renderRecordPage();

const probe = await settledProbe(true);
expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('false');
});
});
35 changes: 33 additions & 2 deletions ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,12 @@ const COMMENT_REACTION_OBJECT = 'sys_comment_reaction';
/** The object the record feed's comments and replies are rows of. */
const COMMENT_OBJECT = 'sys_comment';

/**
* `sys_approval_request`'s declared recall. While the decision bar draws it,
* the DetailView approval band yields its own Recall (objectstack#25877).
*/
const APPROVAL_RECALL_ACTION = 'approval_recall';

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

// A decision landed through the declared-action bar (objectui#3055). The
// action itself already POSTed and the runtime already toasted; what the HOST
Expand Down Expand Up @@ -3676,6 +3695,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
approvalReadAnswered={approvals.answered}
approvalProgress={approvalProgress}
approvalIsSubmitter={approvalIsSubmitter}
approvalRecallDrawnByHost={approvalRecallDrawnByHost}
lockedReason={t('detail.lockedTooltip', {
defaultValue: 'This record has a pending approval request; editing is locked',
})}
Expand Down Expand Up @@ -3808,15 +3828,26 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
renders a richer remind (throttle-aware copy, timeline
refresh), and `exclude` is how a host keeps one of an
object's declared actions in its own UI without the bar
double-rendering it. */}
{approvals.available && approvals.pendingRequest && (
double-rendering it.

objectstack#25877 — for an administrator who is NOT an
approver, the override levers (approve / reject / reassign
in their name, and recall) are the exception path, so
`foldOverrides` collapses them into one status line and one
marked menu; the approver's own bar is unchanged. While the
bar draws the request's Recall, it is the page's one Recall:
`onDrawnActionsChange` is how the page learns that and tells
the DetailView band below to keep its lock badge only. */}
{approvalBarMounted && (
<DeclaredActionsBar
className="mb-4"
objectName={SYS_APPROVAL_REQUEST_OBJECT}
record={approvals.pendingRequest}
location={RECORD_APPROVAL_ACTION_LOCATION}
exclude={[...RECORD_APPROVAL_EXCLUDED_ACTIONS]}
onDone={handleApprovalActionDone}
foldOverrides
onDrawnActionsChange={setApprovalBarDrawn}
/>
)}
<RelatedRecordActionsBridge
Expand Down
Loading
Loading