diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index c912181..8e8b8c6 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -22,6 +22,8 @@ import { updateObject, useApiClient, useDetail, + useList, + type ActionDescriptor, type CustomView, type DeletePreviewResponse, type DetailResponse, @@ -184,6 +186,19 @@ export function DetailPage() { const [historyOpen, setHistoryOpen] = useState(false); const { plural: modelPlural } = useModelMeta(appLabel, modelName); + // Changelist actions on the detail page (#555). `ModelAdmin.actions` is + // surfaced in the list response, not the detail response, so we read it + // through `useList` with `pageSize: 1` — cheap and idempotent (the data + // layer caches it; arrives essentially free for users who came from the + // list). The action runner is the **same** endpoint the changelist uses, + // called with a one-pk array; the SPA pulls in `requires_confirmation`, + // `message_user` toasts, and the intermediate-redirect-in-new-tab flow + // unchanged. + const listMeta = useList({ client, appLabel, modelName, page: 1, pageSize: 1 }); + const detailActions: ActionDescriptor[] = listMeta.data?.actions ?? []; + const [pendingAction, setPendingAction] = useState(null); + const [runningAction, setRunningAction] = useState(false); + if (loading && !data) return ; if (error && !data) { return ; @@ -197,6 +212,53 @@ export function DetailPage() { // changelist filters (#441) when they arrived from a filtered list. const listPath = listPathWithPreservedFilters(`/${appLabel}/${modelName}`, searchParams); + // Detail-page action runner (#555). Mirrors the changelist's runner + // exactly — `requires_confirmation` opens the styled confirm modal; + // otherwise runs immediately. The wire payload is a one-pk array + // (`[pk]`), so the server-side permission gate + queryset filter + // operates over a single row identically to the changelist flow. + function requestDetailAction(action: ActionDescriptor): void { + if (runningAction) return; + if (action.requires_confirmation) { + setPendingAction(action); + } else { + void performDetailAction(action); + } + } + + async function performDetailAction(action: ActionDescriptor): Promise { + if (runningAction) return; + setRunningAction(true); + setPendingAction(null); + try { + const result = await client.runAction(appLabel, modelName, action.name, [pk]); + // Intermediate / form-returning action (#250): the server forwards + // the action's Location as `redirect`; open it in a new tab so the + // operator can complete the flow there — the SPA stays mounted. + if (result.redirect) { + window.open(result.redirect, '_blank', 'noopener,noreferrer'); + toast.info(`${action.label} opened in a new tab.`); + return; + } + await refresh(); + // Prefer the action's own `message_user` output (#442). + const msgs = result.messages ?? []; + if (msgs.length > 0) { + for (const m of msgs) { + if (m.level === 'error' || m.level === 'warning') toast.error(m.message); + else if (m.level === 'info' || m.level === 'debug') toast.info(m.message); + else toast.success(m.message); + } + } else { + toast.success(`${action.label} — “${data?.label ?? ''}”.`); + } + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Action failed.'); + } finally { + setRunningAction(false); + } + } + return (
@@ -261,6 +323,23 @@ export function DetailPage() { onError={(message) => toast.error(message)} /> ))} + {/* Changelist actions on the detail page (#555) — render only + when the user has change permission, mirroring the bulk + runner's visibility on the list page. Each button fires + the same `runAction` endpoint with a one-pk array. */} + {canChange && + detailActions.map((action) => ( + + ))} {canChange && ( + + + } + > +

+ Run {pendingAction.label} on{' '} + “{data.label}”? +

+ + )}
); }