Skip to content

Commit 93db281

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(spa): detail-page header in 3 stacked full-width rows (#658) + 1.8.1 (#661)
The previous header laid out breadcrumb + title + action toolbar on a single horizontal flex row, with breadcrumb + title on the left (``flex-1 min-w-0``) and the toolbar on the right (``shrink-0``, wrapping right-justified). Two failure modes followed: 1. **Long single-token titles collapsed to one-word-per-line at full H1 size.** A filename / slug / UUID like ``Bk stmt docs-pp0_4-bank_statement-2024_12_09-first_bank_and_trust.pdf`` in a narrow title column wrapped on every hyphen at full H1 size, filling the entire viewport vertically before any content showed. 2. **Many actions overflowed and pushed the title off-screen.** A ModelAdmin with 8+ ``@admin.action``s + Edit + Delete made the toolbar wider than the available row, eating the title column entirely. The user saw a wall of buttons but no record name. ## Fix: three stacked full-width rows row 1 — breadcrumb (full width) row 2 — H1 (full width, ``break-words`` so long tokens wrap inside the container instead of forcing each segment to its own line; ``text-balance`` for shorter multi-word titles) row 3 — toolbar (full width, ``flex-wrap`` so 8+ actions flow onto subsequent lines; primary actions Edit/Delete right-aligned via ``ml-auto`` on a cluster div so the destructive button stays at the trailing edge) Each concern now owns its own row — they never share horizontal space, so neither can crowd the other off-screen. Matches what classic Django admin and modern SPA admin frameworks (Refine, React-Admin, Retool) do; scales cleanly from 1 action to 20+ with no breakpoints. The primary-actions cluster (Refresh + Edit + Delete) is wrapped in its own ``ml-auto`` div with its own ``flex-wrap`` — so even when the leading @admin.action cluster wraps to multiple rows, Edit / Delete stay adjacent and at the trailing edge. The destructive Delete is never orphaned from the constructive Edit on a narrow viewport. ## Verification - ``pnpm test`` — 222 / 222 ✓ (no regressions; header changes are layout-only and not covered by existing component tests) - ``pnpm -r typecheck`` ✓ - ``pnpm lint`` ✓ Visual smoke-test pending CodeRabbit + the release pilot loop. Closes #658. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 536d3a6 commit 93db281

2 files changed

Lines changed: 68 additions & 50 deletions

File tree

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

Lines changed: 67 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -237,30 +237,40 @@ export function DetailPage({
237237

238238
return (
239239
<div className="space-y-4">
240-
{/* Header (#572): the title is the page's most important element
241-
and gets as much horizontal space as it needs (`flex-1
242-
min-w-0`); the toolbar is `shrink-0` and only pushes the title
243-
when it genuinely can't fit on its row. `justify-end` on the
244-
toolbar's flex-wrap keeps wrapped button rows flush right to
245-
the page padding, instead of left-aligned within their column. */}
246-
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
247-
<div className="min-w-0 flex-1 space-y-1">
248-
<Breadcrumb
249-
items={[
250-
{ label: 'Home', to: '/' },
251-
{ label: modelPlural, to: listPath },
252-
{ label: data.label },
253-
]}
254-
renderLink={(to, className, label) => (
255-
<Link to={to} className={className}>
256-
{label}
257-
</Link>
258-
)}
259-
/>
260-
<h1 className="text-2xl font-semibold">{data.label}</h1>
261-
</div>
240+
{/* Header (#572 / #658): three stacked full-width rows so the
241+
title never shares horizontal space with the toolbar — the
242+
old side-by-side layout collapsed long single-token titles
243+
(filenames, slugs, UUIDs) into one-word-per-line at full H1
244+
size, AND let an 8+ action toolbar push the title clean off
245+
the viewport. Each concern now owns its own row:
246+
247+
row 1: breadcrumb (full width, truncates on tight viewports)
248+
row 2: H1 (full width, `overflow-wrap: anywhere` so a
249+
single long token wraps inside the container)
250+
row 3: toolbar (full width, `flex-wrap` so 8+ actions flow
251+
to new lines; primary actions Edit/Delete
252+
sit at the trailing edge via `ml-auto`) */}
253+
<header className="space-y-2">
254+
<Breadcrumb
255+
items={[
256+
{ label: 'Home', to: '/' },
257+
{ label: modelPlural, to: listPath },
258+
{ label: data.label },
259+
]}
260+
renderLink={(to, className, label) => (
261+
<Link to={to} className={className}>
262+
{label}
263+
</Link>
264+
)}
265+
/>
266+
{/* `break-words` (Tailwind's overflow-wrap: anywhere) keeps a
267+
very long single-token title wrapping inside the container
268+
at H1 size, instead of forcing each hyphen segment onto its
269+
own line. `text-balance` rebalances the wrap for shorter
270+
multi-word titles too. */}
271+
<h1 className="text-2xl font-semibold text-balance break-words">{data.label}</h1>
262272
{!editing && (
263-
<div className="flex shrink-0 flex-wrap justify-end gap-2">
273+
<div className="flex flex-wrap items-center gap-2">
264274
<button
265275
type="button"
266276
onClick={() => setHistoryOpen(true)}
@@ -343,33 +353,41 @@ export function DetailPage({
343353
onError={(message) => toast.error(message)}
344354
/>
345355
))}
346-
{/* Refresh (#592): refetch the object + inlines + history
347-
with no full page reload. Placed between the actions
348-
cluster and the Edit / Delete pair so destructive
349-
buttons stay at the trailing edge. */}
350-
<RefreshButton
351-
onRefresh={refresh}
352-
tooltip="Refresh"
353-
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
354-
/>
355-
{canChange && (
356-
<Button variant="primary" onClick={() => setEditing(true)}>
357-
<span className="inline-flex items-center gap-1.5">
358-
<Pencil className="h-4 w-4" aria-hidden /> Edit
359-
</span>
360-
</Button>
361-
)}
362-
{canDelete && (
363-
<DeleteButton
364-
label={data.label}
365-
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
366-
onConfirm={async () => {
367-
await deleteObject({ client, appLabel, modelName, pk });
368-
toast.success(`Deleted “${data.label}”.`);
369-
navigate(listPath);
370-
}}
356+
{/* Primary-actions cluster (Refresh + Edit + Delete) is
357+
grouped with ``ml-auto`` so it floats to the trailing
358+
edge of the toolbar row even when the leading
359+
``@admin.action`` cluster wraps onto multiple lines.
360+
``flex-wrap`` on the cluster itself keeps Edit / Delete
361+
together if the row is too narrow for the whole group
362+
— destructive Delete stays adjacent to the constructive
363+
Edit, never orphaned. */}
364+
<div className="ml-auto flex flex-wrap items-center gap-2">
365+
{/* Refresh (#592): refetch the object + inlines + history
366+
with no full page reload. */}
367+
<RefreshButton
368+
onRefresh={refresh}
369+
tooltip="Refresh"
370+
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
371371
/>
372-
)}
372+
{canChange && (
373+
<Button variant="primary" onClick={() => setEditing(true)}>
374+
<span className="inline-flex items-center gap-1.5">
375+
<Pencil className="h-4 w-4" aria-hidden /> Edit
376+
</span>
377+
</Button>
378+
)}
379+
{canDelete && (
380+
<DeleteButton
381+
label={data.label}
382+
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
383+
onConfirm={async () => {
384+
await deleteObject({ client, appLabel, modelName, pk });
385+
toast.success(`Deleted “${data.label}”.`);
386+
navigate(listPath);
387+
}}
388+
/>
389+
)}
390+
</div>
373391
</div>
374392
)}
375393
</header>

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.8.0"
3+
version = "1.8.1"
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)