diff --git a/.changeset/approvals-composer-retire-file-params.md b/.changeset/approvals-composer-retire-file-params.md new file mode 100644 index 000000000..bf64076a5 --- /dev/null +++ b/.changeset/approvals-composer-retire-file-params.md @@ -0,0 +1,23 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/fields": patch +--- + +feat(action-params): serialize file/image action params to storage id(s); retire the approvals composer + +Declared action params of `type: 'file'`/`'image'` now POST the portable API +contract — the storage id(s) — instead of the upload widget's rich object: + +- `FileField` surfaces the id it already receives from the upload adapter + (`meta.fileId`) as `file_id` on each emitted file object (additive; the + record file-field value shape is unchanged). +- `ActionParamDialog` maps upload-param values to their `file_id`(s) at submit + (`serializeParamValues`, pure + exported): single → string, `multiple` → + `string[]`. The api handler already forwards param values untouched, so an + action with a `file` param POSTs `attachments: string[]`. + +This lets the approvals inbox retire its last hand-wired UI — the approve/reject +composer with its bespoke attachment upload — so the drawer renders every +decision through `DeclaredActionsBar` with the declared `attachments` file param +(framework side declares it; see the paired framework change). `DeclaredActionsBar`'s +`exclude` prop stays as a general capability. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index bddce05b9..22d2a8b90 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -24,9 +24,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; -import { CommentAttachment, type Attachment } from '@object-ui/plugin-detail'; import { DeclaredActionsBar } from '@object-ui/app-shell'; -import { createObjectStackUploadAdapter } from '@object-ui/providers'; import { createAuthenticatedFetch } from '@object-ui/auth'; import { Button, @@ -48,8 +46,6 @@ import { SheetHeader, SheetTitle, SheetDescription, - Textarea, - Label, Skeleton, Empty, EmptyTitle, @@ -76,7 +72,7 @@ import { cn, } from '@object-ui/components'; import { toast } from 'sonner'; -import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth'; +import { useAuth } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { CheckCircle2, @@ -252,7 +248,6 @@ function payloadSummary( export function ApprovalsInboxPage() { const { t, language } = useObjectTranslation(); const { user } = useAuth(); - const isAdmin = useIsWorkspaceAdmin(); const { appName } = useParams<{ appName?: string }>(); // Deep link (#2678 P1.5): notifications carry `/system/approvals?request=` // so landing here opens that request's drawer directly. Consumed once, then @@ -336,41 +331,13 @@ export function ApprovalsInboxPage() { const [selected, setSelected] = useState(null); const [actions, setActions] = useState([]); const [drawerLoading, setDrawerLoading] = useState(false); - const [comment, setComment] = useState(''); - const [actorOverride, setActorOverride] = useState(''); - const [submitting, setSubmitting] = useState<'approve' | 'reject' | 'recall' | null>(null); - // Decision attachments (#3266): files staged in the composer, sent as - // `attachments: fileId[]` with the next approve/reject. Uploads go through - // the same presigned-storage adapter as RecordAttachmentsPanel. - const [pendingAttachments, setPendingAttachments] = useState([]); - const [attachmentError, setAttachmentError] = useState(null); + // Approve/reject/reassign/send-back/… are server-declared actions rendered by + // DeclaredActionsBar (objectui#2697 + framework#3300); their param dialog + // collects the comment and — since the shared upload-widget renderer (#2700/ + // #2707) plus the declared `attachments` file param (#2698) — file + // attachments, so the inbox no longer hand-wires a decision composer. `authFetch` + // stays for the timeline's attachment-name resolution and signed-URL open. const authFetch = useMemo(() => createAuthenticatedFetch(), []); - const uploadAdapter = useMemo( - () => createObjectStackUploadAdapter({ - baseUrl: (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, ''), - scope: 'attachments', - fetchImpl: authFetch, - }), - [authFetch], - ); - const handleAttachmentUpload = useCallback(async (files: FileList) => { - setAttachmentError(null); - for (const file of Array.from(files)) { - try { - const result = await uploadAdapter.upload(file); - const fileId = String((result.meta as { fileId?: string } | undefined)?.fileId ?? ''); - if (!fileId) throw new Error('upload returned no fileId'); - setPendingAttachments(prev => [...prev, { - id: fileId, name: result.name, size: result.size, type: result.mimeType, url: result.url, - }]); - } catch (err) { - setAttachmentError((err as Error)?.message ?? String(err)); - } - } - }, [uploadAdapter]); - const handleAttachmentRemove = useCallback((attachmentId: string) => { - setPendingAttachments(prev => prev.filter(a => a.id !== attachmentId)); - }, []); // Resolve attachment fileIds → sys_file display names for the timeline chips // (#2678 P1.5). Best-effort, cached per id; unresolved ids fall back to a // generic "Attachment" label. @@ -529,10 +496,6 @@ export function ApprovalsInboxPage() { const openDrawer = useCallback(async (id: string) => { setSelectedId(id); setDrawerLoading(true); - setComment(''); - setActorOverride(''); - setPendingAttachments([]); - setAttachmentError(null); try { const [req, acts] = await Promise.all([ approvalsApi.getRequest(id), @@ -553,10 +516,6 @@ export function ApprovalsInboxPage() { setSelectedId(null); setSelected(null); setActions([]); - setComment(''); - setActorOverride(''); - setPendingAttachments([]); - setAttachmentError(null); }; // Consume the notification deep link once (see useSearchParams above). @@ -575,14 +534,6 @@ export function ApprovalsInboxPage() { * 2. First identity that intersects `pending_approvers`. * 3. User id fallback. */ - const resolveActor = useCallback((req: ApprovalRequestRow | null): string => { - if (actorOverride.trim()) return actorOverride.trim(); - if (!req) return user?.id || ''; - const pending = new Set(req.pending_approvers || []); - for (const id of identities) if (pending.has(id)) return id; - return user?.id || ''; - }, [actorOverride, identities, user?.id]); - const refreshBadge = useCallback(() => { if (!identities.length) return; approvalsApi.listRequests({ status: 'pending', approverId: identities }) @@ -590,63 +541,6 @@ export function ApprovalsInboxPage() { .catch(() => { /* best-effort */ }); }, [identities]); - const doAction = useCallback(async (kind: 'approve' | 'reject' | 'recall') => { - if (!selected) return; - const actor = kind === 'recall' ? (user?.id || resolveActor(selected)) : resolveActor(selected); - if (!actor) { - toast.error(tr('noActor', 'Cannot determine the acting identity')); - return; - } - setSubmitting(kind); - try { - const body = { - actor_id: actor, - comment: comment.trim() || undefined, - // Decision attachments (#3266) ride approve/reject only — recall has no composer. - ...(kind !== 'recall' && pendingAttachments.length - ? { attachments: pendingAttachments.map(a => a.id) } - : {}), - }; - const fn = kind === 'approve' ? approvalsApi.approve - : kind === 'reject' ? approvalsApi.reject - : approvalsApi.recall; - const res = await fn(selected.id, body); - toast.success( - kind === 'approve' - ? (res.finalized ? tr('approvedFinal', 'Approved') : tr('approvedWaiting', 'Approved — waiting on the remaining approvers')) - : kind === 'reject' ? tr('rejectedToast', 'Rejected') - : tr('recalledToast', 'Request recalled'), - ); - setComment(''); - setPendingAttachments([]); - setAttachmentError(null); - // Queue processing (Fiori "My Inbox" pattern): a decision on the - // pending tab advances straight to the next waiting item instead of - // parking on the finished one. Recall keeps the drawer for review. - if (kind !== 'recall' && tab === 'pending') { - const list = filteredRef.current; - const idx = list.findIndex(r => r.id === selected.id); - const next = list[idx + 1] ?? list[idx - 1]; - void load(); - if (next && next.id !== selected.id) void openDrawer(next.id); - else closeDrawer(); - return; - } - // Refresh drawer + list. - const [req, acts] = await Promise.all([ - approvalsApi.getRequest(selected.id), - approvalsApi.listActions(selected.id), - ]); - setSelected(req.data); - setActions(acts.data); - void load(); - } catch (err: any) { - toast.error(humanizeError(err, tr('actionFailed', 'Action failed'))); - } finally { - setSubmitting(null); - } - }, [selected, resolveActor, comment, pendingAttachments, load, user?.id, humanizeError, tr, tab, openDrawer]); - /** Refresh the open drawer + list after a thread interaction. */ const refreshThread = useCallback(async (id: string) => { const [req, acts] = await Promise.all([ @@ -658,6 +552,40 @@ export function ApprovalsInboxPage() { void load(); }, [load]); + /** + * `onDone` for the pending drawer's declared-action bar. Refreshes the acted + * request + list, then — on the pending tab — advances to the next waiting + * item when this one is no longer the current user's to act on (finalized, + * approved-away in a quorum, reassigned): the Fiori "My Inbox" queue-processing + * flow the hand-wired composer used to own, now driven generically off the + * refreshed row rather than a per-action handler. Stays in place otherwise + * (e.g. remind / request-info leave the item pending and still yours). + */ + const onDecisionDone = useCallback(async () => { + if (!selected) return; + const id = selected.id; + try { + const [req, acts] = await Promise.all([ + approvalsApi.getRequest(id), + approvalsApi.listActions(id), + ]); + setSelected(req.data); + setActions(acts.data); + void load(); + const pending = new Set(req.data.pending_approvers || []); + const stillMine = req.data.status === 'pending' && identities.some(x => pending.has(x)); + if (tab === 'pending' && !stillMine) { + const list = filteredRef.current; + const idx = list.findIndex(r => r.id === id); + const next = list[idx + 1] ?? list[idx - 1]; + if (next && next.id !== id) { void openDrawer(next.id); return; } + closeDrawer(); + } + } catch { + void refreshThread(id); + } + }, [selected, identities, tab, load, openDrawer, closeDrawer, refreshThread]); + const doReply = useCallback(async () => { if (!selected || !reply.trim()) return; setThreadBusy(true); @@ -672,22 +600,26 @@ export function ApprovalsInboxPage() { } }, [selected, reply, user?.id, refreshThread, humanizeError, tr]); + // Participant checks — drive the reply box + the "why disabled" hint (the + // decision buttons themselves are server-declared and gate via their own + // `visible` CEL). No actor-override branch: the admin "act as" escape hatch + // retired with the composer (cloud#861 enterprise act-as is the successor). const canApproveReject = useMemo(() => { if (!selected || selected.status !== 'pending') return false; const pending = new Set(selected.pending_approvers || []); - return identities.some(id => pending.has(id)) || actorOverride.trim().length > 0; - }, [selected, identities, actorOverride]); + return identities.some(id => pending.has(id)); + }, [selected, identities]); const canRecall = useMemo(() => { if (!selected || selected.status !== 'pending') return false; - return selected.submitter_id === user?.id || actorOverride.trim().length > 0; - }, [selected, user?.id, actorOverride]); + return selected.submitter_id === user?.id; + }, [selected, user?.id]); /** ADR-0044: the submitter may resubmit (or abandon) a returned request. */ const canResubmit = useMemo(() => { if (!selected || selected.status !== 'returned') return false; - return selected.submitter_id === user?.id || actorOverride.trim().length > 0; - }, [selected, user?.id, actorOverride]); + return selected.submitter_id === user?.id; + }, [selected, user?.id]); /** Unique process labels present in current rows (for filter dropdown). */ @@ -1686,105 +1618,25 @@ export function ApprovalsInboxPage() { <>
- {isAdmin && ( -
- - {tr('overrideActor', 'Act as another identity (admin)')} - -
- - setActorOverride(e.target.value)} - placeholder={`${tr('auto', 'Auto')}: ${resolveActor(selected) || '—'}`} - className="w-full mt-1 px-3 py-2 text-sm border rounded-md bg-background" - /> -
- {tr('overrideHint', 'e.g. role:sales_manager. Leave blank to use the auto-detected identity.')} -
-
-
- )} -
- -
- {[ - tr('quickPhrase1', 'Approved — meets requirements.'), - tr('quickPhrase2', 'Approved with conditions — please monitor execution.'), - tr('quickPhrase3', 'Please add supporting material and resubmit.'), - ].map((p) => ( - - ))} -
-