Skip to content

Commit 2e44b8a

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(spa): detail toolbar buttons inline, drop ml-auto cluster (#677) + 1.11.2 (#678)
History / Refresh / Edit / Delete were grouped in a right-aligned `ml-auto` cluster (#658/#672), which read as a second toolbar in its own column and could float disconnected from its row on narrow viewports. They are now plain buttons in the single `flex-wrap` toolbar, flowing inline with the custom `@admin.action` buttons and wrapping naturally — DOM order `[History] [...custom actions] [Refresh] [Edit] [Delete]`, no spacer. Destructive emphasis on Delete stays the button's own variant, not position. Flips the two regression tests that pinned the `ml-auto` cluster to assert the no-ml-auto inline contract (DOM order preserved). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3f4ef04 commit 2e44b8a

4 files changed

Lines changed: 75 additions & 55 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.11.2] — 2026-06-02
11+
12+
### Fixed
13+
- **Detail-page toolbar: History / Refresh / Edit / Delete now flow inline
14+
with the custom `@admin.action` buttons (#677).** They were grouped in a
15+
right-aligned `ml-auto` cluster (#658/#672), which read as a *second*
16+
toolbar in its own column and could float disconnected from its row on
17+
narrow viewports. The toolbar is now a single `flex-wrap` container with no
18+
`ml-auto` spacer: every built-in is a plain button in the same flow,
19+
wrapping naturally wherever it falls, in DOM order `[History] [...custom
20+
actions] [Refresh] [Edit] [Delete]`. Destructive emphasis on Delete remains
21+
the button's own variant, not its position. Regression tests updated to pin
22+
the no-`ml-auto` inline contract.
23+
1024
## [1.11.1] — 2026-06-02
1125

1226
### Fixed

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

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,20 @@ describe('DetailPage header (#658 regression guard)', () => {
109109
expect(header?.className).not.toContain('sm:flex-row');
110110
});
111111

112-
it('floats the primary actions (Edit/Delete) to the trailing edge of their own row', () => {
112+
it('renders History/Refresh/Edit/Delete inline, with no ml-auto cluster (#677)', () => {
113113
renderPage();
114114

115115
const edit = screen.getByRole('button', { name: /edit/i });
116-
// #658 row 3: Edit/Delete live in an `ml-auto` cluster so they stay
117-
// trailing-edge even when the leading action cluster wraps.
118-
const cluster = edit.closest('div.ml-auto');
119-
expect(cluster).not.toBeNull();
116+
const history = screen.getByRole('button', { name: /history/i });
117+
const header = edit.closest('header');
118+
// #677: NO `ml-auto` (or any) spacer right-aligns a subset of the
119+
// toolbar — two visual columns read as two toolbars. Every built-in
120+
// is a plain button in the single flex-wrap flow.
121+
expect(header?.querySelector('.ml-auto')).toBeNull();
122+
// History and Edit share the one flex-wrap toolbar container.
123+
const toolbar = history.closest('div.flex.flex-wrap');
124+
expect(toolbar).not.toBeNull();
125+
expect(edit.closest('div.flex.flex-wrap')).toBe(toolbar);
120126
});
121127
});
122128

@@ -160,27 +166,28 @@ describe('DetailPage many-actions toolbar (#672 regression guard)', () => {
160166
expect(toolbar?.contains(title)).toBe(false);
161167
});
162168

163-
it('keeps Edit/Delete right-aligned (ml-auto) on the last line after all 14 actions', () => {
169+
it('renders Edit/Delete inline after the 14 actions in DOM order, no ml-auto (#677)', () => {
164170
detailState = detail({ object_actions: MANY_ACTIONS as never });
165171
renderPage();
166172

167173
const edit = screen.getByRole('button', { name: /edit/i });
168-
const cluster = edit.closest('div.ml-auto');
169-
expect(cluster).not.toBeNull();
170-
// Delete lives in the SAME trailing cluster, never orphaned.
171174
const del = screen.getByRole('button', { name: /delete/i });
172-
expect(cluster?.contains(del)).toBe(true);
173-
174-
// The trailing cluster comes AFTER every custom action in DOM order, so
175-
// `ml-auto` parks it on the last toolbar line.
176175
const toolbar = edit.closest('div.flex.flex-wrap');
176+
expect(toolbar).not.toBeNull();
177+
// #677: no right-aligned cluster anywhere in the toolbar — Edit and
178+
// Delete flow inline with the custom actions in the same container.
179+
expect(toolbar?.querySelector('.ml-auto')).toBeNull();
180+
expect(del.closest('div.flex.flex-wrap')).toBe(toolbar);
181+
182+
// Render order == DOM order: built-ins still come AFTER every custom
183+
// action (no reordering / skipping), they're just not right-aligned.
184+
const children = Array.from(toolbar?.children ?? []);
177185
const lastAction = screen.getByRole('button', {
178186
name: MANY_ACTIONS[MANY_ACTIONS.length - 1]!.label,
179187
});
180-
const children = Array.from(toolbar?.children ?? []);
181188
const lastActionIdx = children.findIndex((c) => c.contains(lastAction));
182-
const clusterIdx = children.findIndex((c) => c.contains(edit));
183-
expect(clusterIdx).toBeGreaterThan(lastActionIdx);
189+
const editIdx = children.findIndex((c) => c.contains(edit));
190+
expect(editIdx).toBeGreaterThan(lastActionIdx);
184191
});
185192

186193
it('lets long action labels wrap inside the button (no wide min-content box)', () => {

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

Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,12 @@ export function DetailPage({
115115
row 2: H1 (full width, `overflow-wrap: anywhere` so a
116116
single long token wraps inside the container)
117117
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`)
118+
to new lines; History / Refresh / Edit /
119+
Delete are plain buttons in the same flow,
120+
no right-aligned cluster — #677)
120121
121-
NOTE: re-applied after the #657 module-split refactor silently
122-
reverted it to the pre-#658 single-row layout. */}
122+
NOTE: the stacked rows were re-applied after the #657
123+
module-split refactor silently reverted them (#674). */}
123124
<header className="space-y-2">
124125
<Breadcrumb
125126
items={[
@@ -228,41 +229,39 @@ export function DetailPage({
228229
onError={(message) => toast.error(message)}
229230
/>
230231
))}
231-
{/* Primary-actions cluster (Refresh + Edit + Delete) is
232-
grouped with `ml-auto` so it floats to the trailing
233-
edge of the toolbar row even when the leading
234-
`@admin.action` cluster wraps onto multiple lines.
235-
`flex-wrap` on the cluster itself keeps Edit / Delete
236-
together if the row is too narrow for the whole group
237-
— destructive Delete stays adjacent to the constructive
238-
Edit, never orphaned. */}
239-
<div className="ml-auto flex flex-wrap items-center gap-2">
240-
{/* Refresh (#592): refetch the object + inlines + history
241-
with no full page reload. */}
242-
<RefreshButton
243-
onRefresh={refresh}
244-
tooltip="Refresh"
245-
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
232+
{/* History / Refresh / Edit / Delete are plain toolbar
233+
buttons (#677): they flow inline with the custom
234+
`@admin.action` buttons in the same `flex-wrap` row and
235+
wrap naturally wherever they fall. No `ml-auto` spacer or
236+
"primary cluster" right-alignment — two visual columns in
237+
one toolbar read as two separate toolbars. Destructive
238+
emphasis on Delete is the button's own `variant`, never
239+
its position. */}
240+
{/* Refresh (#592): refetch the object + inlines + history
241+
with no full page reload. */}
242+
<RefreshButton
243+
onRefresh={refresh}
244+
tooltip="Refresh"
245+
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
246+
/>
247+
{canChange && (
248+
<Button variant="primary" onClick={() => setEditing(true)}>
249+
<span className="inline-flex items-center gap-1.5">
250+
<Pencil className="h-4 w-4" aria-hidden /> Edit
251+
</span>
252+
</Button>
253+
)}
254+
{canDelete && (
255+
<DeleteButton
256+
label={data.label}
257+
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
258+
onConfirm={async () => {
259+
await deleteObject({ client, appLabel, modelName, pk });
260+
toast.success(`Deleted “${data.label}”.`);
261+
navigate(listPath);
262+
}}
246263
/>
247-
{canChange && (
248-
<Button variant="primary" onClick={() => setEditing(true)}>
249-
<span className="inline-flex items-center gap-1.5">
250-
<Pencil className="h-4 w-4" aria-hidden /> Edit
251-
</span>
252-
</Button>
253-
)}
254-
{canDelete && (
255-
<DeleteButton
256-
label={data.label}
257-
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
258-
onConfirm={async () => {
259-
await deleteObject({ client, appLabel, modelName, pk });
260-
toast.success(`Deleted “${data.label}”.`);
261-
navigate(listPath);
262-
}}
263-
/>
264-
)}
265-
</div>
264+
)}
266265
</div>
267266
)}
268267
</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.11.1"
3+
version = "1.11.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)