Skip to content

Commit c74e252

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): detail-page admin action buttons reusing the changelist actions API (#555) (#562)
Closes #555. Surface `ModelAdmin.actions` as buttons on the detail page, alongside `History` / `View on site` / `Edit` / `Delete`. Each button calls the **same** changelist action endpoint the list page uses — just with a one-pk array (`[pk]`) — so there's no new wire surface and the existing permission gate / queryset filter / `message_user` / intermediate-redirect-in-new-tab flow all apply unchanged. - Imports: `useList`, `ActionDescriptor` from `@dar/data`. - `DetailResponse` doesn't carry `actions` (they live in the list response — `django-admin-rest-api` owns that wire shape, no change), so DetailPage reads the metadata through `useList({ pageSize: 1 })`. The data layer caches it; for a user who arrived from the list it's essentially free. - `requestDetailAction` + `performDetailAction` mirror the list-page flow: `requires_confirmation` opens the same styled confirm modal (re-reading "Run X on *this object*?"), else runs immediately; `result.redirect` opens in a new tab (the #250 minimum); messages surface as toasts (#442). - Buttons gated by `canChange` — same visibility rule the bulk runner uses on the changelist. Vitest: 145 passed. Typecheck + ESLint (--max-warnings 0) + stylelint + dark-mode guard clean. `pnpm -r build` ok. Closes #555 Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 87f5d6f commit c74e252

1 file changed

Lines changed: 104 additions & 0 deletions

File tree

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
updateObject,
2323
useApiClient,
2424
useDetail,
25+
useList,
26+
type ActionDescriptor,
2527
type CustomView,
2628
type DeletePreviewResponse,
2729
type DetailResponse,
@@ -184,6 +186,19 @@ export function DetailPage() {
184186
const [historyOpen, setHistoryOpen] = useState(false);
185187
const { plural: modelPlural } = useModelMeta(appLabel, modelName);
186188

189+
// Changelist actions on the detail page (#555). `ModelAdmin.actions` is
190+
// surfaced in the list response, not the detail response, so we read it
191+
// through `useList` with `pageSize: 1` — cheap and idempotent (the data
192+
// layer caches it; arrives essentially free for users who came from the
193+
// list). The action runner is the **same** endpoint the changelist uses,
194+
// called with a one-pk array; the SPA pulls in `requires_confirmation`,
195+
// `message_user` toasts, and the intermediate-redirect-in-new-tab flow
196+
// unchanged.
197+
const listMeta = useList({ client, appLabel, modelName, page: 1, pageSize: 1 });
198+
const detailActions: ActionDescriptor[] = listMeta.data?.actions ?? [];
199+
const [pendingAction, setPendingAction] = useState<ActionDescriptor | null>(null);
200+
const [runningAction, setRunningAction] = useState(false);
201+
187202
if (loading && !data) return <RecordSkeleton />;
188203
if (error && !data) {
189204
return <EmptyState title="Couldn't load the object" description={error.message} />;
@@ -197,6 +212,53 @@ export function DetailPage() {
197212
// changelist filters (#441) when they arrived from a filtered list.
198213
const listPath = listPathWithPreservedFilters(`/${appLabel}/${modelName}`, searchParams);
199214

215+
// Detail-page action runner (#555). Mirrors the changelist's runner
216+
// exactly — `requires_confirmation` opens the styled confirm modal;
217+
// otherwise runs immediately. The wire payload is a one-pk array
218+
// (`[pk]`), so the server-side permission gate + queryset filter
219+
// operates over a single row identically to the changelist flow.
220+
function requestDetailAction(action: ActionDescriptor): void {
221+
if (runningAction) return;
222+
if (action.requires_confirmation) {
223+
setPendingAction(action);
224+
} else {
225+
void performDetailAction(action);
226+
}
227+
}
228+
229+
async function performDetailAction(action: ActionDescriptor): Promise<void> {
230+
if (runningAction) return;
231+
setRunningAction(true);
232+
setPendingAction(null);
233+
try {
234+
const result = await client.runAction(appLabel, modelName, action.name, [pk]);
235+
// Intermediate / form-returning action (#250): the server forwards
236+
// the action's Location as `redirect`; open it in a new tab so the
237+
// operator can complete the flow there — the SPA stays mounted.
238+
if (result.redirect) {
239+
window.open(result.redirect, '_blank', 'noopener,noreferrer');
240+
toast.info(`${action.label} opened in a new tab.`);
241+
return;
242+
}
243+
await refresh();
244+
// Prefer the action's own `message_user` output (#442).
245+
const msgs = result.messages ?? [];
246+
if (msgs.length > 0) {
247+
for (const m of msgs) {
248+
if (m.level === 'error' || m.level === 'warning') toast.error(m.message);
249+
else if (m.level === 'info' || m.level === 'debug') toast.info(m.message);
250+
else toast.success(m.message);
251+
}
252+
} else {
253+
toast.success(`${action.label} — “${data?.label ?? ''}”.`);
254+
}
255+
} catch (e) {
256+
toast.error(e instanceof Error ? e.message : 'Action failed.');
257+
} finally {
258+
setRunningAction(false);
259+
}
260+
}
261+
200262
return (
201263
<div className="space-y-4">
202264
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
@@ -261,6 +323,23 @@ export function DetailPage() {
261323
onError={(message) => toast.error(message)}
262324
/>
263325
))}
326+
{/* Changelist actions on the detail page (#555) — render only
327+
when the user has change permission, mirroring the bulk
328+
runner's visibility on the list page. Each button fires
329+
the same `runAction` endpoint with a one-pk array. */}
330+
{canChange &&
331+
detailActions.map((action) => (
332+
<button
333+
key={action.name}
334+
type="button"
335+
onClick={() => requestDetailAction(action)}
336+
disabled={runningAction}
337+
title={action.description}
338+
className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50"
339+
>
340+
{action.label}
341+
</button>
342+
))}
264343
{canChange && (
265344
<Button variant="primary" onClick={() => setEditing(true)}>
266345
Edit
@@ -358,6 +437,31 @@ export function DetailPage() {
358437
onClose={() => setHistoryOpen(false)}
359438
/>
360439
)}
440+
441+
{/* Detail-page action confirm modal (#555) — same styled
442+
confirmation as the changelist (#206), reads exactly "Run X on
443+
this object?" to make the single-pk scope explicit. */}
444+
{pendingAction && (
445+
<Modal
446+
title="Confirm action"
447+
onClose={() => setPendingAction(null)}
448+
footer={
449+
<>
450+
<Button variant="secondary" onClick={() => setPendingAction(null)}>
451+
Cancel
452+
</Button>
453+
<Button variant="primary" onClick={() => void performDetailAction(pendingAction)}>
454+
Run
455+
</Button>
456+
</>
457+
}
458+
>
459+
<p className="text-sm text-gray-700">
460+
Run <span className="font-medium">{pendingAction.label}</span> on{' '}
461+
<span className="font-medium">{data.label}</span>?
462+
</p>
463+
</Modal>
464+
)}
361465
</div>
362466
);
363467
}

0 commit comments

Comments
 (0)