From 8c1685a7dd32ed2c57755f8699481d3718d7e51f Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Thu, 28 May 2026 18:44:38 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.0.2=20=E2=80=94=20filter-r?= =?UTF-8?q?ow=20wrap,=20detail-page=20actions=20+=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "
" — 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) --- frontend/apps/web/src/pages/DetailPage.tsx | 120 +++------------------ frontend/packages/search/src/FilterBar.tsx | 34 +++++- pyproject.toml | 2 +- 3 files changed, 48 insertions(+), 108 deletions(-) diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index 8e8b8c6..af17bf5 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -22,8 +22,6 @@ import { updateObject, useApiClient, useDetail, - useList, - type ActionDescriptor, type CustomView, type DeletePreviewResponse, type DetailResponse, @@ -186,18 +184,13 @@ 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); + // Detail-page action buttons (#571): per-object actions only, via + // `django-object-actions` (`change_actions`). Surfaced by the API as + // `data.object_actions` and rendered below via . + // The original #555 attempt — running `ModelAdmin.actions` (changelist + // bulk actions) on a one-pk slice — was the wrong primitive: bulk + // actions are *list* semantics and confused the operator on a + // single-object page. Reverted in v1.0.2. if (loading && !data) return ; if (error && !data) { @@ -212,57 +205,16 @@ 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 (
+ {/* Header (#572): the title is the page's most important element + and gets as much horizontal space as it needs (`flex-1 + min-w-0`); the toolbar is `shrink-0` and only pushes the title + when it genuinely can't fit on its row. `justify-end` on the + toolbar's flex-wrap keeps wrapped button rows flush right to + the page padding, instead of left-aligned within their column. */}
-
+
{data.label}
{!editing && ( -
+
- ))} {canChange && ( - - - } - > -

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

- - )}
); } diff --git a/frontend/packages/search/src/FilterBar.tsx b/frontend/packages/search/src/FilterBar.tsx index 8c40470..7ba1488 100644 --- a/frontend/packages/search/src/FilterBar.tsx +++ b/frontend/packages/search/src/FilterBar.tsx @@ -9,7 +9,15 @@ // trailing slot is pinned right (the page composes it so the row ends in // "Clear all" then "Customize"). -import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react'; +import { + Children, + cloneElement, + isValidElement, + useEffect, + useState, + type KeyboardEvent, + type ReactNode, +} from 'react'; import { Check, ChevronDown } from 'lucide-react'; import type { FilterDescriptor, FilterOption } from '@dar/data'; @@ -104,7 +112,29 @@ export function FilterBar({ onChange={(v) => onFilterChange(f.name, v)} /> ))} - {trailing ?
{trailing}
: null} + {/* Trailing slot (#554, #570): rendered as DIRECT children of the + outer flex-wrap container — not inside a sub-wrapper — so the + trailing buttons participate in the same wrap pass as the + filter pills and stay glued to the end of the last pill row. + `ml-auto` is injected on the first non-null trailing child to + push the cluster to the row's right edge; if a wrap occurs the + trailing items wrap WITH the pills, never as a separate row. + A sub-wrapper here would behave as one flex item and produce + the "buttons on their own line" symptom #570 reported. */} + {(() => { + const items = Children.toArray(trailing).filter(Boolean); + let firstReal = true; + return items.map((child, i) => { + if (!isValidElement<{ className?: string }>(child)) return child; + const isFirst = firstReal; + firstReal = false; + const extra = isFirst ? ' ml-auto' : ''; + return cloneElement(child, { + className: `${child.props.className ?? ''}${extra}`, + key: child.key ?? i, + }); + }); + })()}
); } diff --git a/pyproject.toml b/pyproject.toml index b93dd9f..144a2a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-admin-react" -version = "1.0.1" +version = "1.0.2" description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin." authors = ["django-admin-react contributors"] license = "MIT"