diff --git a/CHANGELOG.md b/CHANGELOG.md index 0598527..f69c790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/frontend/apps/web/src/pages/DetailPage.test.tsx b/frontend/apps/web/src/pages/DetailPage.test.tsx index e0732fc..3467fc2 100644 --- a/frontend/apps/web/src/pages/DetailPage.test.tsx +++ b/frontend/apps/web/src/pages/DetailPage.test.tsx @@ -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); }); }); @@ -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)', () => { diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index 2f59e7d..0756ea2 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -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). */}
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. */} -
- {/* Refresh (#592): refetch the object + inlines + history - with no full page reload. */} - } + {/* 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. */} + } + /> + {canChange && ( + + )} + {canDelete && ( + fetchDeletePreview({ client, appLabel, modelName, pk })} + onConfirm={async () => { + await deleteObject({ client, appLabel, modelName, pk }); + toast.success(`Deleted “${data.label}”.`); + navigate(listPath); + }} /> - {canChange && ( - - )} - {canDelete && ( - fetchDeletePreview({ client, appLabel, modelName, pk })} - onConfirm={async () => { - await deleteObject({ client, appLabel, modelName, pk }); - toast.success(`Deleted “${data.label}”.`); - navigate(listPath); - }} - /> - )} -
+ )} )}
diff --git a/pyproject.toml b/pyproject.toml index 898108f..ca4418c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"