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.10.1] — 2026-06-02

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

## [1.10.0] — 2026-06-02

### Added
Expand Down
86 changes: 86 additions & 0 deletions frontend/apps/web/src/pages/DetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import '@testing-library/jest-dom/vitest';

import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';

import type { DetailResponse } from '@dar/data';

// Minimal read-mode detail payload — enough for the header to render with
// the title + toolbar (Refresh / Edit / Delete are permission-gated on).
function detail(): DetailResponse {
return {
app_label: 'auth',
model_name: 'group',
pk: 1,
label: 'editors',
permissions: { view: true, add: true, change: true, delete: true },
fieldsets: [],
fields: {},
inlines: [],
object_actions: [],
custom_views: [],
save_options: { show_save: true },
} as unknown as DetailResponse;
}

vi.mock('@dar/data', async (importOriginal) => {
const actual = await importOriginal<typeof import('@dar/data')>();
return {
...actual,
useApiClient: () => ({}),
useDetail: () => ({ data: detail(), loading: false, error: null, refresh: async () => {} }),
};
});

vi.mock('../useModelMeta', () => ({
useModelMeta: () => ({ plural: 'Groups' }),
}));

vi.mock('../toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
toastMessages: vi.fn(),
}));

// Import AFTER the mocks so DetailPage picks them up.
const { DetailPage } = await import('./DetailPage');

function renderPage() {
return render(
<MemoryRouter initialEntries={['/auth/group/1']}>
<Routes>
<Route path="/:appLabel/:modelName/:pk" element={<DetailPage />} />
</Routes>
</MemoryRouter>,
);
}

describe('DetailPage header (#658 regression guard)', () => {
// The #657 module-split refactor silently reverted #658, reintroducing the
// single-row `sm:flex-row sm:justify-between` header where a long title
// collapsed to one-word-per-line and 8+ actions pushed the title
// off-screen. These assertions fail the moment that layout returns.
it('renders the title and toolbar as stacked full-width rows', () => {
renderPage();

const title = screen.getByRole('heading', { level: 1, name: 'editors' });
// #658 row 2: the title wraps inside its own full-width row.
expect(title.className).toContain('break-words');

const header = title.closest('header');
expect(header).not.toBeNull();
// Stacked rows, NOT the pre-#658 side-by-side flex row.
expect(header?.className).toContain('space-y-2');
expect(header?.className).not.toContain('sm:flex-row');
});

it('floats the primary actions (Edit/Delete) to the trailing edge of their own row', () => {
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();
});
});
119 changes: 70 additions & 49 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,30 +104,43 @@ export function DetailPage({

return (
<div className="space-y-4">
{/* Header (#572): the title is the page's most important element
and gets as much horizontal space as it needs (`flex-1
min-w-0`); the toolbar is `shrink-0` and only pushes the title
when it genuinely can't fit on its row. `justify-end` on the
toolbar's flex-wrap keeps wrapped button rows flush right to
the page padding, instead of left-aligned within their column. */}
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1 space-y-1">
<Breadcrumb
items={[
{ label: 'Home', to: '/' },
{ label: modelPlural, to: listPath },
{ label: data.label },
]}
renderLink={(to, className, label) => (
<Link to={to} className={className}>
{label}
</Link>
)}
/>
<h1 className="text-2xl font-semibold">{data.label}</h1>
</div>
{/* Header (#572 / #658): three stacked full-width rows so the
title never shares horizontal space with the toolbar — the
old side-by-side layout collapsed long single-token titles
(filenames, slugs, UUIDs) into one-word-per-line at full H1
size, AND let an 8+ action toolbar push the title clean off
the viewport. Each concern now owns its own row:

row 1: breadcrumb (full width, truncates on tight viewports)
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`)

NOTE: re-applied after the #657 module-split refactor silently
reverted it to the pre-#658 single-row layout. */}
<header className="space-y-2">
<Breadcrumb
items={[
{ label: 'Home', to: '/' },
{ label: modelPlural, to: listPath },
{ label: data.label },
]}
renderLink={(to, className, label) => (
<Link to={to} className={className}>
{label}
</Link>
)}
/>
{/* `break-words` (Tailwind's overflow-wrap: anywhere) keeps a
very long single-token title wrapping inside the container
at H1 size, instead of forcing each hyphen segment onto its
own line. `text-balance` rebalances the wrap for shorter
multi-word titles too. */}
<h1 className="text-2xl font-semibold text-balance break-words">{data.label}</h1>
{!editing && (
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setHistoryOpen(true)}
Expand Down Expand Up @@ -210,33 +223,41 @@ export function DetailPage({
onError={(message) => toast.error(message)}
/>
))}
{/* Refresh (#592): refetch the object + inlines + history
with no full page reload. Placed between the actions
cluster and the Edit / Delete pair so destructive
buttons stay at the trailing edge. */}
<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);
}}
{/* 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 />}
/>
)}
{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
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-admin-react"
version = "1.10.0"
version = "1.10.1"
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
authors = ["django-admin-react contributors"]
license = "MIT"
Expand Down Expand Up @@ -58,7 +58,10 @@ django = ">=4.2,<7.0"
# Raised to 1.5.0 in v1.10.0: the form-spec `legacy-iframe` fallback now
# covers request-driven custom views (a `change_view` override that renders
# a non-standard template), which the `examples/jobs` fixture exercises.
django-admin-rest-api = "^1.5.0"
# Raised to 1.6.0 in v1.11.0 (#664): the form-spec wire now emits
# `prepopulated_fields` and autocomplete hints, which the SPA's widget-kind
# rendering consumes (slugify-on-keystroke + autocomplete widget).
django-admin-rest-api = "^1.6.0"
# `django-admin-mcp-api` — MCP-protocol adapter over the same REST API
# so agents reach the SAME `ModelAdmin`-driven surface. Wire-protocol-only
# layer; adds NO new functionality, permissions, or validation.
Expand Down
Loading