From de1c0137f790346fa1952835f30cffbbaa70e3e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:50:23 +0000 Subject: [PATCH] feat(approvals-inbox): retire hardcoded secondary buttons for server-declared actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes objectui#2678 P2-4: the approvals inbox no longer hand-wires a button per decision capability. With the full declared action set now on sys_approval_request (framework#3300), DeclaredActionsBar renders and executes them, so the inbox drops its bespoke send-back / request-info / reassign / remind / recall / resubmit buttons — plus the three AlertDialogs, five action handlers, and the lazy user-directory loader that backed them. - DeclaredActionsBar gains an `exclude` prop. The inbox keeps approve/reject in its richer composer (it stages file attachments the generic param dialog can't yet collect) and `exclude`s them from the bar, so the two never render duplicate buttons. - Pending drawer: the "More actions" bar covers reassign/send-back/request-info (approver) and remind/recall (submitter, gated by the declared `visible` CEL). - Returned drawer: keeps the Edit-record link; resubmit and recall-abandon come from the bar (gated to the submitter on a returned request). Net: ~230 fewer lines of inbox-specific action code; new decision capabilities now ship as backend metadata (enterprise act-as, cloud#861, included). Verified in-browser against the live backend: the drawer shows approve/reject in the composer and send-back/request-info/reassign/remind/recall in "More actions" with zero duplicates; resubmit correctly hidden on a pending request; Reassign opens a real sys_user picker; clicking declared "Send reminder" executed /remind and appended a `remind` audit row. app-shell suite green (7 DeclaredActionsBar tests incl. exclude); console typecheck clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016ypkQikZ55oWUHUnMebwXA --- .../src/pages/system/ApprovalsInboxPage.tsx | 348 ++---------------- .../src/views/DeclaredActionsBar.tsx | 20 +- .../__tests__/DeclaredActionsBar.test.tsx | 30 ++ 3 files changed, 79 insertions(+), 319 deletions(-) diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index f64883489..bddce05b9 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -81,7 +81,6 @@ import { useObjectTranslation } from '@object-ui/i18n'; import { CheckCircle2, XCircle, - Undo2, Clock, RefreshCw, AlertCircle, @@ -93,12 +92,8 @@ import { User as UserIcon, ChevronLeft, ChevronRight, - ArrowRightLeft, - BellRing, - HelpCircle, Send, Check, - CornerUpLeft, Paperclip, } from 'lucide-react'; import { @@ -435,18 +430,14 @@ export function ApprovalsInboxPage() { const [rejectTarget, setRejectTarget] = useState(null); const [inlineActing, setInlineActing] = useState(null); - // Thread interactions (reassign / request-info / reply / remind) - const [reassignOpen, setReassignOpen] = useState(false); - const [reassignTo, setReassignTo] = useState(''); - const [requestInfoOpen, setRequestInfoOpen] = useState(false); - const [requestInfoText, setRequestInfoText] = useState(''); - // Send back for revision (ADR-0044) — a flow movement, unlike request-info. - const [sendBackOpen, setSendBackOpen] = useState(false); - const [sendBackText, setSendBackText] = useState(''); - const [resubmitting, setResubmitting] = useState(false); + // Thread reply. The secondary decision levers (reassign / request-info / + // send-back / remind / recall / resubmit) are no longer hand-wired here — + // they ship as the object's server-declared actions and render through + // DeclaredActionsBar (objectui#2678 P2-4 + framework#3300). The rich + // approve/reject composer below stays, because it collects file attachments + // the generic param dialog can't yet. const [reply, setReply] = useState(''); const [threadBusy, setThreadBusy] = useState(false); - const [userOptions, setUserOptions] = useState>([]); // Keyboard row focus const [focusIndex, setFocusIndex] = useState(-1); @@ -667,106 +658,6 @@ export function ApprovalsInboxPage() { void load(); }, [load]); - const doReassign = useCallback(async () => { - if (!selected || !reassignTo.trim()) return; - setThreadBusy(true); - try { - await approvalsApi.reassign(selected.id, { - actor_id: resolveActor(selected), to: reassignTo.trim(), comment: comment.trim() || undefined, - }); - toast.success(tr('reassignSuccess', 'Handed to {{to}}', { to: reassignTo.trim() })); - setReassignOpen(false); - setReassignTo(''); - setComment(''); - await refreshThread(selected.id); - refreshBadge(); - } catch (err: any) { - toast.error(humanizeError(err, tr('actionFailed', 'Action failed'))); - } finally { - setThreadBusy(false); - } - }, [selected, reassignTo, comment, resolveActor, refreshThread, refreshBadge, humanizeError, tr]); - - const doRemind = useCallback(async () => { - if (!selected) return; - setThreadBusy(true); - try { - const res = await approvalsApi.remind(selected.id, { actor_id: user?.id }); - toast.success(tr('remindSuccess', 'Reminder sent to {{count}} approver(s)', { count: res.notified })); - await refreshThread(selected.id); - } catch (err: any) { - toast.error(humanizeError(err, tr('actionFailed', 'Action failed'))); - } finally { - setThreadBusy(false); - } - }, [selected, user?.id, refreshThread, humanizeError, tr]); - - const doRequestInfo = useCallback(async () => { - if (!selected || !requestInfoText.trim()) return; - setThreadBusy(true); - try { - await approvalsApi.requestInfo(selected.id, { - actor_id: resolveActor(selected), comment: requestInfoText.trim(), - }); - toast.success(tr('requestInfoSent', 'Sent back to the requester for more information')); - setRequestInfoOpen(false); - setRequestInfoText(''); - await refreshThread(selected.id); - } catch (err: any) { - toast.error(humanizeError(err, tr('actionFailed', 'Action failed'))); - } finally { - setThreadBusy(false); - } - }, [selected, requestInfoText, resolveActor, refreshThread, humanizeError, tr]); - - /** - * Send back for revision (ADR-0044): finalizes this round as `returned`, - * unlocks the record, and parks the flow until the submitter resubmits. - * Past the node's revision budget the server auto-rejects instead. - */ - const doSendBack = useCallback(async () => { - if (!selected) return; - setThreadBusy(true); - try { - const res = await approvalsApi.sendBack(selected.id, { - actor_id: resolveActor(selected), comment: sendBackText.trim() || undefined, - }); - toast.success(res.autoRejected - ? tr('sendBackAutoRejected', 'Revision limit reached — the request was auto-rejected') - : tr('sendBackSuccess', 'Sent back for revision — the requester can now edit and resubmit')); - setSendBackOpen(false); - setSendBackText(''); - await refreshThread(selected.id); - refreshBadge(); - } catch (err: any) { - toast.error(humanizeError(err, tr('actionFailed', 'Action failed'))); - } finally { - setThreadBusy(false); - } - }, [selected, sendBackText, resolveActor, refreshThread, refreshBadge, humanizeError, tr]); - - /** - * Resubmit after rework (ADR-0044, submitter): the flow re-enters the - * approval node and opens the next round's request. - */ - const doResubmit = useCallback(async () => { - if (!selected) return; - setResubmitting(true); - try { - await approvalsApi.resubmit(selected.id, { - actor_id: user?.id, comment: comment.trim() || undefined, - }); - toast.success(tr('resubmitSuccess', 'Resubmitted — a new approval round has opened')); - setComment(''); - await refreshThread(selected.id); - refreshBadge(); - } catch (err: any) { - toast.error(humanizeError(err, tr('actionFailed', 'Action failed'))); - } finally { - setResubmitting(false); - } - }, [selected, comment, user?.id, refreshThread, refreshBadge, humanizeError, tr]); - const doReply = useCallback(async () => { if (!selected || !reply.trim()) return; setThreadBusy(true); @@ -781,19 +672,6 @@ export function ApprovalsInboxPage() { } }, [selected, reply, user?.id, refreshThread, humanizeError, tr]); - /** Lazy user directory for the reassign picker (name + email datalist). */ - const loadUserOptions = useCallback(async () => { - if (userOptions.length) return; - try { - const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, ''); - const res = await fetch(`${base}/api/v1/data/sys_user?limit=100`, { credentials: 'include' }); - const j = await res.json(); - setUserOptions(((j.records || []) as any[]) - .filter(u => u.email && u.name && u.id !== user?.id) - .map(u => ({ name: String(u.name), email: String(u.email) }))); - } catch { /* picker degrades to free text */ } - }, [userOptions.length, user?.id]); - const canApproveReject = useMemo(() => { if (!selected || selected.status !== 'pending') return false; const pending = new Set(selected.pending_approvers || []); @@ -1477,88 +1355,6 @@ export function ApprovalsInboxPage() { - {/* Reassign dialog */} - { if (!open) { setReassignOpen(false); setReassignTo(''); } }}> - - - {tr('reassignTitle', 'Hand this approval to someone else?')} - - {tr('reassignBody', 'Your approver slot moves to the person you pick — they are notified and can act immediately.')} - - -
- - setReassignTo(e.target.value)} - placeholder={tr('reassignToPlaceholder', 'Pick a user or type an email / role:')} - className="mt-1" - /> - - {userOptions.map(u => ( - - ))} - -
- - {tr('cancel', 'Cancel')} - void doReassign()}> - {tr('reassignBtn', 'Reassign')} - - -
-
- - {/* Request-info dialog */} - { if (!open) { setRequestInfoOpen(false); setRequestInfoText(''); } }}> - - - {tr('requestInfoTitle', 'Ask the requester for more information?')} - - {tr('requestInfoBody', 'The request stays pending; the requester is notified and can reply on the thread.')} - - -