Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.11.2] — 2026-06-02

### Fixed
- **Detail-page toolbar: History / Refresh / Edit / Delete now flow inline
with the custom `@admin.action` buttons (#677).** They 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. The toolbar is now a single `flex-wrap` container with no
`ml-auto` spacer: every built-in is a plain button in the same flow,
wrapping naturally wherever it falls, in DOM order `[History] [...custom
actions] [Refresh] [Edit] [Delete]`. Destructive emphasis on Delete remains
the button's own variant, not its position. Regression tests updated to pin
the no-`ml-auto` inline contract.

## [1.11.1] — 2026-06-02

### Fixed
Expand Down
39 changes: 23 additions & 16 deletions frontend/apps/web/src/pages/DetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,20 @@ describe('DetailPage header (#658 regression guard)', () => {
expect(header?.className).not.toContain('sm:flex-row');
});

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

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

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

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

const edit = screen.getByRole('button', { name: /edit/i });
const cluster = edit.closest('div.ml-auto');
expect(cluster).not.toBeNull();
// Delete lives in the SAME trailing cluster, never orphaned.
const del = screen.getByRole('button', { name: /delete/i });
expect(cluster?.contains(del)).toBe(true);

// The trailing cluster comes AFTER every custom action in DOM order, so
// `ml-auto` parks it on the last toolbar line.
const toolbar = edit.closest('div.flex.flex-wrap');
expect(toolbar).not.toBeNull();
// #677: no right-aligned cluster anywhere in the toolbar — Edit and
// Delete flow inline with the custom actions in the same container.
expect(toolbar?.querySelector('.ml-auto')).toBeNull();
expect(del.closest('div.flex.flex-wrap')).toBe(toolbar);

// Render order == DOM order: built-ins still come AFTER every custom
// action (no reordering / skipping), they're just not right-aligned.
const children = Array.from(toolbar?.children ?? []);
const lastAction = screen.getByRole('button', {
name: MANY_ACTIONS[MANY_ACTIONS.length - 1]!.label,
});
const children = Array.from(toolbar?.children ?? []);
const lastActionIdx = children.findIndex((c) => c.contains(lastAction));
const clusterIdx = children.findIndex((c) => c.contains(edit));
expect(clusterIdx).toBeGreaterThan(lastActionIdx);
const editIdx = children.findIndex((c) => c.contains(edit));
expect(editIdx).toBeGreaterThan(lastActionIdx);
});

it('lets long action labels wrap inside the button (no wide min-content box)', () => {
Expand Down
75 changes: 37 additions & 38 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,12 @@ export function DetailPage({
row 2: H1 (full width, `overflow-wrap: anywhere` so a
single long token wraps inside the container)
row 3: toolbar (full width, `flex-wrap` so 8+ actions flow
to new lines; primary actions Edit/Delete
sit at the trailing edge via `ml-auto`)
to new lines; History / Refresh / Edit /
Delete are plain buttons in the same flow,
no right-aligned cluster — #677)

NOTE: re-applied after the #657 module-split refactor silently
reverted it to the pre-#658 single-row layout. */}
NOTE: the stacked rows were re-applied after the #657
module-split refactor silently reverted them (#674). */}
<header className="space-y-2">
<Breadcrumb
items={[
Expand Down Expand Up @@ -228,41 +229,39 @@ export function DetailPage({
onError={(message) => toast.error(message)}
/>
))}
{/* Primary-actions cluster (Refresh + Edit + Delete) is
grouped with `ml-auto` so it floats to the trailing
edge of the toolbar row even when the leading
`@admin.action` cluster wraps onto multiple lines.
`flex-wrap` on the cluster itself keeps Edit / Delete
together if the row is too narrow for the whole group
— destructive Delete stays adjacent to the constructive
Edit, never orphaned. */}
<div className="ml-auto flex flex-wrap items-center gap-2">
{/* Refresh (#592): refetch the object + inlines + history
with no full page reload. */}
<RefreshButton
onRefresh={refresh}
tooltip="Refresh"
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
{/* History / Refresh / Edit / Delete are plain toolbar
buttons (#677): they flow inline with the custom
`@admin.action` buttons in the same `flex-wrap` row and
wrap naturally wherever they fall. No `ml-auto` spacer or
"primary cluster" right-alignment — two visual columns in
one toolbar read as two separate toolbars. Destructive
emphasis on Delete is the button's own `variant`, never
its position. */}
{/* Refresh (#592): refetch the object + inlines + history
with no full page reload. */}
<RefreshButton
onRefresh={refresh}
tooltip="Refresh"
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
/>
{canChange && (
<Button variant="primary" onClick={() => setEditing(true)}>
<span className="inline-flex items-center gap-1.5">
<Pencil className="h-4 w-4" aria-hidden /> Edit
</span>
</Button>
)}
{canDelete && (
<DeleteButton
label={data.label}
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
onConfirm={async () => {
await deleteObject({ client, appLabel, modelName, pk });
toast.success(`Deleted “${data.label}”.`);
navigate(listPath);
}}
/>
{canChange && (
<Button variant="primary" onClick={() => setEditing(true)}>
<span className="inline-flex items-center gap-1.5">
<Pencil className="h-4 w-4" aria-hidden /> Edit
</span>
</Button>
)}
{canDelete && (
<DeleteButton
label={data.label}
loadPreview={() => fetchDeletePreview({ client, appLabel, modelName, pk })}
onConfirm={async () => {
await deleteObject({ client, appLabel, modelName, pk });
toast.success(`Deleted “${data.label}”.`);
navigate(listPath);
}}
/>
)}
</div>
)}
</div>
)}
</header>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-admin-react"
version = "1.11.1"
version = "1.11.2"
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
authors = ["django-admin-react contributors"]
license = "MIT"
Expand Down
Loading