Skip to content

Commit 8c1685a

Browse files
chore(release): v1.0.2 — filter-row wrap, detail-page actions + header
Three SPA polish bugs reported against v1.0.1, all addressed in this PR; no API / contract / schema change. #570 — list filter row wrap (FilterBar trailing slot) ----------------------------------------------------- The trailing slot rendered its children inside a sub-wrapper "<div className=ml-auto flex flex-wrap>" — which behaved as one flex item in the outer container. When the filter pills wrapped onto a second line, the trailing wrapper wrapped as a unit and ml-auto pushed it to the right edge of the new (empty) line — producing the visually-separate "second toolbar row" the pilot reported. Fix: render the trailing children as DIRECT siblings of the pills, no sub-wrapper. React.Children.toArray + cloneElement injects ml-auto on the first non-null trailing child to push the cluster right; flex-wrap then keeps the trailing buttons glued to the end of the last pill row, never on a separate line. #571 — per-object actions on detail (single-pk semantics) --------------------------------------------------------- PR #562 (closing #555) wired the detail page to the CHANGELIST actions endpoint with a one-pk array. That was the wrong primitive: bulk-action verbs (selected files, selected items) are list semantics and confused the operator on a single-object page, and the per-object change_actions from django-object-actions (which the API already surfaces as data.object_actions) were visually buried. Fix: drop the changelist-actions rendering from DetailPage entirely. ObjectActionButton (already wired to runObjectAction → POST app/model/pk/action/name/) remains; it is the correct single-pk primitive. Side effect: ~1.4 kB drop in the SPA bundle from removing the unused useList, runAction, confirm-modal, and runner code paths. #572 — detail header layout (title squeezed, toolbar not right-aligned) ----------------------------------------------------------------------- The header used sm:justify-between with no width hint on either side, so the title block + toolbar block split horizontal space roughly 50/50 even when the title needed more and the toolbar would fit in less. The toolbar block had flex flex-wrap but no justify-end, so wrapped button rows were left-aligned within the right column — reading as "centered between title and viewport" rather than "right-aligned to the page". Fix: title block → min-w-0 flex-1 (claims all available width, truncates only when it has to). Toolbar block → shrink-0 flex-wrap justify-end. Toolbar only pushes the title when its content genuinely needs the room, and wrapped button rows hug the right edge. Closes #570, #571, #572. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d792d0 commit 8c1685a

3 files changed

Lines changed: 48 additions & 108 deletions

File tree

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

Lines changed: 15 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ import {
2222
updateObject,
2323
useApiClient,
2424
useDetail,
25-
useList,
26-
type ActionDescriptor,
2725
type CustomView,
2826
type DeletePreviewResponse,
2927
type DetailResponse,
@@ -186,18 +184,13 @@ export function DetailPage() {
186184
const [historyOpen, setHistoryOpen] = useState(false);
187185
const { plural: modelPlural } = useModelMeta(appLabel, modelName);
188186

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);
187+
// Detail-page action buttons (#571): per-object actions only, via
188+
// `django-object-actions` (`change_actions`). Surfaced by the API as
189+
// `data.object_actions` and rendered below via <ObjectActionButton>.
190+
// The original #555 attempt — running `ModelAdmin.actions` (changelist
191+
// bulk actions) on a one-pk slice — was the wrong primitive: bulk
192+
// actions are *list* semantics and confused the operator on a
193+
// single-object page. Reverted in v1.0.2.
201194

202195
if (loading && !data) return <RecordSkeleton />;
203196
if (error && !data) {
@@ -212,57 +205,16 @@ export function DetailPage() {
212205
// changelist filters (#441) when they arrived from a filtered list.
213206
const listPath = listPathWithPreservedFilters(`/${appLabel}/${modelName}`, searchParams);
214207

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-
262208
return (
263209
<div className="space-y-4">
210+
{/* Header (#572): the title is the page's most important element
211+
and gets as much horizontal space as it needs (`flex-1
212+
min-w-0`); the toolbar is `shrink-0` and only pushes the title
213+
when it genuinely can't fit on its row. `justify-end` on the
214+
toolbar's flex-wrap keeps wrapped button rows flush right to
215+
the page padding, instead of left-aligned within their column. */}
264216
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
265-
<div className="space-y-1">
217+
<div className="min-w-0 flex-1 space-y-1">
266218
<Breadcrumb
267219
items={[
268220
{ label: 'Home', to: '/' },
@@ -278,7 +230,7 @@ export function DetailPage() {
278230
<h1 className="text-2xl font-semibold">{data.label}</h1>
279231
</div>
280232
{!editing && (
281-
<div className="flex flex-wrap gap-2">
233+
<div className="flex shrink-0 flex-wrap justify-end gap-2">
282234
<button
283235
type="button"
284236
onClick={() => setHistoryOpen(true)}
@@ -323,23 +275,6 @@ export function DetailPage() {
323275
onError={(message) => toast.error(message)}
324276
/>
325277
))}
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-
))}
343278
{canChange && (
344279
<Button variant="primary" onClick={() => setEditing(true)}>
345280
Edit
@@ -437,31 +372,6 @@ export function DetailPage() {
437372
onClose={() => setHistoryOpen(false)}
438373
/>
439374
)}
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-
)}
465375
</div>
466376
);
467377
}

frontend/packages/search/src/FilterBar.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@
99
// trailing slot is pinned right (the page composes it so the row ends in
1010
// "Clear all" then "Customize").
1111

12-
import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react';
12+
import {
13+
Children,
14+
cloneElement,
15+
isValidElement,
16+
useEffect,
17+
useState,
18+
type KeyboardEvent,
19+
type ReactNode,
20+
} from 'react';
1321
import { Check, ChevronDown } from 'lucide-react';
1422

1523
import type { FilterDescriptor, FilterOption } from '@dar/data';
@@ -104,7 +112,29 @@ export function FilterBar({
104112
onChange={(v) => onFilterChange(f.name, v)}
105113
/>
106114
))}
107-
{trailing ? <div className="ml-auto flex flex-wrap items-center gap-2">{trailing}</div> : null}
115+
{/* Trailing slot (#554, #570): rendered as DIRECT children of the
116+
outer flex-wrap container — not inside a sub-wrapper — so the
117+
trailing buttons participate in the same wrap pass as the
118+
filter pills and stay glued to the end of the last pill row.
119+
`ml-auto` is injected on the first non-null trailing child to
120+
push the cluster to the row's right edge; if a wrap occurs the
121+
trailing items wrap WITH the pills, never as a separate row.
122+
A sub-wrapper here would behave as one flex item and produce
123+
the "buttons on their own line" symptom #570 reported. */}
124+
{(() => {
125+
const items = Children.toArray(trailing).filter(Boolean);
126+
let firstReal = true;
127+
return items.map((child, i) => {
128+
if (!isValidElement<{ className?: string }>(child)) return child;
129+
const isFirst = firstReal;
130+
firstReal = false;
131+
const extra = isFirst ? ' ml-auto' : '';
132+
return cloneElement(child, {
133+
className: `${child.props.className ?? ''}${extra}`,
134+
key: child.key ?? i,
135+
});
136+
});
137+
})()}
108138
</div>
109139
);
110140
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.0.1"
3+
version = "1.0.2"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"

0 commit comments

Comments
 (0)