Skip to content

Commit c77d6b1

Browse files
fix(spa): restore detail-page stacked header reverted by #657 + 1.10.1
The #657 module-split refactor was branched from before #658 landed, so merging it silently restored the pre-#658 single-row header (breadcrumb + title sharing a flex row with the toolbar). That reintroduced both bugs #658 fixed: long single-token titles collapsing to one-word-per-line at H1 size, and an 8+ action toolbar pushing the title off-screen. Re-applies the three stacked full-width rows (breadcrumb / title with overflow-wrap:anywhere via break-words / toolbar with Edit·Delete pinned trailing-edge via ml-auto). Adds a DetailPage test asserting the stacked layout (break-words, header space-y-2, ml-auto cluster, no sm:flex-row) so a future refactor can't revert it unnoticed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 45dd07d commit c77d6b1

4 files changed

Lines changed: 175 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.10.1] — 2026-06-02
11+
12+
### Fixed
13+
- **Restored the detail-page stacked-header layout (#658), which the #657
14+
module-split refactor silently reverted.** The header had regressed to the
15+
pre-#658 single-row layout where breadcrumb + title share a flex row with
16+
the action toolbar — collapsing long single-token titles (filenames,
17+
slugs, UUIDs) to one-word-per-line at full H1 size, and letting an 8+
18+
action toolbar push the title off-screen. The header is again three
19+
stacked full-width rows (breadcrumb / title with `overflow-wrap: anywhere`
20+
/ toolbar with Edit·Delete pinned trailing-edge via `ml-auto`). Added a
21+
`DetailPage` test asserting the stacked layout so a future refactor can't
22+
revert it unnoticed.
23+
1024
## [1.10.0] — 2026-06-02
1125

1226
### Added
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import '@testing-library/jest-dom/vitest';
2+
3+
import { render, screen } from '@testing-library/react';
4+
import { describe, expect, it, vi } from 'vitest';
5+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
6+
7+
import type { DetailResponse } from '@dar/data';
8+
9+
// Minimal read-mode detail payload — enough for the header to render with
10+
// the title + toolbar (Refresh / Edit / Delete are permission-gated on).
11+
function detail(): DetailResponse {
12+
return {
13+
app_label: 'auth',
14+
model_name: 'group',
15+
pk: 1,
16+
label: 'editors',
17+
permissions: { view: true, add: true, change: true, delete: true },
18+
fieldsets: [],
19+
fields: {},
20+
inlines: [],
21+
object_actions: [],
22+
custom_views: [],
23+
save_options: { show_save: true },
24+
} as unknown as DetailResponse;
25+
}
26+
27+
vi.mock('@dar/data', async (importOriginal) => {
28+
const actual = await importOriginal<typeof import('@dar/data')>();
29+
return {
30+
...actual,
31+
useApiClient: () => ({}),
32+
useDetail: () => ({ data: detail(), loading: false, error: null, refresh: async () => {} }),
33+
};
34+
});
35+
36+
vi.mock('../useModelMeta', () => ({
37+
useModelMeta: () => ({ plural: 'Groups' }),
38+
}));
39+
40+
vi.mock('../toast', () => ({
41+
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
42+
toastMessages: vi.fn(),
43+
}));
44+
45+
// Import AFTER the mocks so DetailPage picks them up.
46+
const { DetailPage } = await import('./DetailPage');
47+
48+
function renderPage() {
49+
return render(
50+
<MemoryRouter initialEntries={['/auth/group/1']}>
51+
<Routes>
52+
<Route path="/:appLabel/:modelName/:pk" element={<DetailPage />} />
53+
</Routes>
54+
</MemoryRouter>,
55+
);
56+
}
57+
58+
describe('DetailPage header (#658 regression guard)', () => {
59+
// The #657 module-split refactor silently reverted #658, reintroducing the
60+
// single-row `sm:flex-row sm:justify-between` header where a long title
61+
// collapsed to one-word-per-line and 8+ actions pushed the title
62+
// off-screen. These assertions fail the moment that layout returns.
63+
it('renders the title and toolbar as stacked full-width rows', () => {
64+
renderPage();
65+
66+
const title = screen.getByRole('heading', { level: 1, name: 'editors' });
67+
// #658 row 2: the title wraps inside its own full-width row.
68+
expect(title.className).toContain('break-words');
69+
70+
const header = title.closest('header');
71+
expect(header).not.toBeNull();
72+
// Stacked rows, NOT the pre-#658 side-by-side flex row.
73+
expect(header?.className).toContain('space-y-2');
74+
expect(header?.className).not.toContain('sm:flex-row');
75+
});
76+
77+
it('floats the primary actions (Edit/Delete) to the trailing edge of their own row', () => {
78+
renderPage();
79+
80+
const edit = screen.getByRole('button', { name: /edit/i });
81+
// #658 row 3: Edit/Delete live in an `ml-auto` cluster so they stay
82+
// trailing-edge even when the leading action cluster wraps.
83+
const cluster = edit.closest('div.ml-auto');
84+
expect(cluster).not.toBeNull();
85+
});
86+
});

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

Lines changed: 70 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -104,30 +104,43 @@ export function DetailPage({
104104

105105
return (
106106
<div className="space-y-4">
107-
{/* Header (#572): the title is the page's most important element
108-
and gets as much horizontal space as it needs (`flex-1
109-
min-w-0`); the toolbar is `shrink-0` and only pushes the title
110-
when it genuinely can't fit on its row. `justify-end` on the
111-
toolbar's flex-wrap keeps wrapped button rows flush right to
112-
the page padding, instead of left-aligned within their column. */}
113-
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
114-
<div className="min-w-0 flex-1 space-y-1">
115-
<Breadcrumb
116-
items={[
117-
{ label: 'Home', to: '/' },
118-
{ label: modelPlural, to: listPath },
119-
{ label: data.label },
120-
]}
121-
renderLink={(to, className, label) => (
122-
<Link to={to} className={className}>
123-
{label}
124-
</Link>
125-
)}
126-
/>
127-
<h1 className="text-2xl font-semibold">{data.label}</h1>
128-
</div>
107+
{/* Header (#572 / #658): three stacked full-width rows so the
108+
title never shares horizontal space with the toolbar — the
109+
old side-by-side layout collapsed long single-token titles
110+
(filenames, slugs, UUIDs) into one-word-per-line at full H1
111+
size, AND let an 8+ action toolbar push the title clean off
112+
the viewport. Each concern now owns its own row:
113+
114+
row 1: breadcrumb (full width, truncates on tight viewports)
115+
row 2: H1 (full width, `overflow-wrap: anywhere` so a
116+
single long token wraps inside the container)
117+
row 3: toolbar (full width, `flex-wrap` so 8+ actions flow
118+
to new lines; primary actions Edit/Delete
119+
sit at the trailing edge via `ml-auto`)
120+
121+
NOTE: re-applied after the #657 module-split refactor silently
122+
reverted it to the pre-#658 single-row layout. */}
123+
<header className="space-y-2">
124+
<Breadcrumb
125+
items={[
126+
{ label: 'Home', to: '/' },
127+
{ label: modelPlural, to: listPath },
128+
{ label: data.label },
129+
]}
130+
renderLink={(to, className, label) => (
131+
<Link to={to} className={className}>
132+
{label}
133+
</Link>
134+
)}
135+
/>
136+
{/* `break-words` (Tailwind's overflow-wrap: anywhere) keeps a
137+
very long single-token title wrapping inside the container
138+
at H1 size, instead of forcing each hyphen segment onto its
139+
own line. `text-balance` rebalances the wrap for shorter
140+
multi-word titles too. */}
141+
<h1 className="text-2xl font-semibold text-balance break-words">{data.label}</h1>
129142
{!editing && (
130-
<div className="flex shrink-0 flex-wrap justify-end gap-2">
143+
<div className="flex flex-wrap items-center gap-2">
131144
<button
132145
type="button"
133146
onClick={() => setHistoryOpen(true)}
@@ -210,33 +223,41 @@ export function DetailPage({
210223
onError={(message) => toast.error(message)}
211224
/>
212225
))}
213-
{/* Refresh (#592): refetch the object + inlines + history
214-
with no full page reload. Placed between the actions
215-
cluster and the Edit / Delete pair so destructive
216-
buttons stay at the trailing edge. */}
217-
<RefreshButton
218-
onRefresh={refresh}
219-
tooltip="Refresh"
220-
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
221-
/>
222-
{canChange && (
223-
<Button variant="primary" onClick={() => setEditing(true)}>
224-
<span className="inline-flex items-center gap-1.5">
225-
<Pencil className="h-4 w-4" aria-hidden /> Edit
226-
</span>
227-
</Button>
228-
)}
229-
{canDelete && (
230-
<DeleteButton
231-
label={data.label}
232-
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
233-
onConfirm={async () => {
234-
await deleteObject({ client, appLabel, modelName, pk });
235-
toast.success(`Deleted “${data.label}”.`);
236-
navigate(listPath);
237-
}}
226+
{/* Primary-actions cluster (Refresh + Edit + Delete) is
227+
grouped with `ml-auto` so it floats to the trailing
228+
edge of the toolbar row even when the leading
229+
`@admin.action` cluster wraps onto multiple lines.
230+
`flex-wrap` on the cluster itself keeps Edit / Delete
231+
together if the row is too narrow for the whole group
232+
— destructive Delete stays adjacent to the constructive
233+
Edit, never orphaned. */}
234+
<div className="ml-auto flex flex-wrap items-center gap-2">
235+
{/* Refresh (#592): refetch the object + inlines + history
236+
with no full page reload. */}
237+
<RefreshButton
238+
onRefresh={refresh}
239+
tooltip="Refresh"
240+
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
238241
/>
239-
)}
242+
{canChange && (
243+
<Button variant="primary" onClick={() => setEditing(true)}>
244+
<span className="inline-flex items-center gap-1.5">
245+
<Pencil className="h-4 w-4" aria-hidden /> Edit
246+
</span>
247+
</Button>
248+
)}
249+
{canDelete && (
250+
<DeleteButton
251+
label={data.label}
252+
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
253+
onConfirm={async () => {
254+
await deleteObject({ client, appLabel, modelName, pk });
255+
toast.success(`Deleted “${data.label}”.`);
256+
navigate(listPath);
257+
}}
258+
/>
259+
)}
260+
</div>
240261
</div>
241262
)}
242263
</header>

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.10.0"
3+
version = "1.10.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"
@@ -58,7 +58,10 @@ django = ">=4.2,<7.0"
5858
# Raised to 1.5.0 in v1.10.0: the form-spec `legacy-iframe` fallback now
5959
# covers request-driven custom views (a `change_view` override that renders
6060
# a non-standard template), which the `examples/jobs` fixture exercises.
61-
django-admin-rest-api = "^1.5.0"
61+
# Raised to 1.6.0 in v1.11.0 (#664): the form-spec wire now emits
62+
# `prepopulated_fields` and autocomplete hints, which the SPA's widget-kind
63+
# rendering consumes (slugify-on-keystroke + autocomplete widget).
64+
django-admin-rest-api = "^1.6.0"
6265
# `django-admin-mcp-api` — MCP-protocol adapter over the same REST API
6366
# so agents reach the SAME `ModelAdmin`-driven surface. Wire-protocol-only
6467
# layer; adds NO new functionality, permissions, or validation.

0 commit comments

Comments
 (0)