diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef25b3..19963ba 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.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 diff --git a/frontend/apps/web/src/pages/DetailPage.test.tsx b/frontend/apps/web/src/pages/DetailPage.test.tsx new file mode 100644 index 0000000..2bfbebc --- /dev/null +++ b/frontend/apps/web/src/pages/DetailPage.test.tsx @@ -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(); + 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( + + + } /> + + , + ); +} + +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(); + }); +}); diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index a62d9a7..ca86229 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -104,30 +104,43 @@ export function DetailPage({ return (
- {/* 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. */} -
-
- ( - - {label} - - )} - /> -

{data.label}

-
+ {/* 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. */} +
+ ( + + {label} + + )} + /> + {/* `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. */} +

{data.label}

{!editing && ( -
+
- )} - {canDelete && ( - 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. */} +
+ {/* 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); + }} + /> + )} +
)}
diff --git a/poetry.lock b/poetry.lock index e39ffde..40bbb36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -488,14 +488,14 @@ jsonschema = ">=4.0,<5.0" [[package]] name = "django-admin-rest-api" -version = "1.5.0" +version = "1.6.0" description = "A JSON REST API for the Django admin — same permissions, same ModelAdmin, no new features. Powers django-admin-react and django-admin-mcp." optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "django_admin_rest_api-1.5.0-py3-none-any.whl", hash = "sha256:3c9e6057f803a01c5d7f78144ea424f0a16206c593323c27fe6c344f6ebde310"}, - {file = "django_admin_rest_api-1.5.0.tar.gz", hash = "sha256:c559a2b04d1fbe43c773d2430c493b50e3d9467e46e23a3546e7985cc609d3fc"}, + {file = "django_admin_rest_api-1.6.0-py3-none-any.whl", hash = "sha256:18cf550223ab19e44610235e43b6052bc8afa0cfa384e7c67b4039ff5f304021"}, + {file = "django_admin_rest_api-1.6.0.tar.gz", hash = "sha256:6f8e79d42f343edddd2663eab43364e449391b8b86df1e82e4c5682a5dc58a4e"}, ] [package.dependencies] @@ -1900,4 +1900,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "cbedae24e804c8e2e963192f3785c7b7ef5d056911ac112467cccc78515c71d1" +content-hash = "754f98cf4853954ac4d142f7430c9ad0b3469cd1c2f2701c9f22f47c1b346cb0" diff --git a/pyproject.toml b/pyproject.toml index a64a041..2ab7141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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.