From e14b19b82bfa0d9c9af84fc9939409d90526ac38 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Tue, 2 Jun 2026 11:59:05 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(spa):=20detail-header=20toolbar=20overf?= =?UTF-8?q?low=20=E2=80=94=20main=20min-w-0=20+=20wrapping=20toolbar=20(#6?= =?UTF-8?q?72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail-page header already stacked breadcrumb / title / toolbar as three full-width rows (#658/#674), but a ModelAdmin with 8+ actions still overflowed horizontally and pushed the H1 + breadcrumb off-screen. Root cause: the content column `
` is a flex item, whose default `min-width: auto` refuses to shrink below its widest content. A toolbar with 12+ buttons made that intrinsic width exceed the viewport, so `flex-1` blew `main` past the viewport edge and dragged every stacked row (title and breadcrumb included) off-screen — no header re-stacking could help. `min-w-0` on `
` lets it shrink to the viewport so the toolbar's `flex-wrap` actually reflows. - Layout: `
` gets `min-w-0`. - DetailPage toolbar row: `w-full min-w-0 flex-wrap`; Edit/Delete cluster stays right-aligned (`ml-auto`) on the last line regardless of action count. - ObjectActionButton: long labels wrap inside the button (`whitespace-normal break-words`) instead of forming a wide min-content box. - examples/many_actions PipelineAdmin fixture: 12 batch + 2 detail-only actions with long descriptions, wired into the examples settings. - DetailPage.test.tsx: guards the wrapping/right-alignment/CSS contract with the 14-action fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/many_actions/__init__.py | 0 examples/many_actions/admin.py | 105 ++++++++++++++++ examples/many_actions/apps.py | 8 ++ .../many_actions/migrations/0001_initial.py | 26 ++++ examples/many_actions/migrations/__init__.py | 0 examples/many_actions/models.py | 19 +++ examples/project/settings.py | 3 + frontend/apps/web/src/Layout.tsx | 15 ++- .../apps/web/src/pages/DetailPage.test.tsx | 117 +++++++++++++++++- frontend/apps/web/src/pages/DetailPage.tsx | 7 +- .../src/pages/detail/ObjectActionButton.tsx | 4 + 11 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 examples/many_actions/__init__.py create mode 100644 examples/many_actions/admin.py create mode 100644 examples/many_actions/apps.py create mode 100644 examples/many_actions/migrations/0001_initial.py create mode 100644 examples/many_actions/migrations/__init__.py create mode 100644 examples/many_actions/models.py diff --git a/examples/many_actions/__init__.py b/examples/many_actions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/many_actions/admin.py b/examples/many_actions/admin.py new file mode 100644 index 00000000..3039e43e --- /dev/null +++ b/examples/many_actions/admin.py @@ -0,0 +1,105 @@ +"""``PipelineAdmin`` — the many-actions fixture that pins the detail-page +toolbar wrapping behaviour (#672). + +The point of this admin is purely the *number* and *width* of its actions. +Stock Django ``@admin.action`` declarations only: + +* **12 batch actions** — default (queryset-shaped) third parameter, so the API + signature classifier marks them ``target: "batch"``. They render BOTH in the + changelist multi-select dropdown AND as buttons on the detail page. +* **2 detail-only actions** — ``obj_id`` / ``str``-shaped third parameter, so + the classifier marks them ``target: "detail"``. They render ONLY as detail + buttons, never in the changelist dropdown. + +14 buttons (several with long descriptions) on one detail page cannot fit on a +single row at 1280/1024/768/480 px. Before #672 the toolbar overflowed +horizontally and pushed the H1 title + breadcrumb off-screen; now the SPA +stacks the header as three full-width rows and ``flex-wrap``s the toolbar. +""" + +from __future__ import annotations + +from django.contrib import admin +from django.contrib import messages + +from examples.many_actions.models import Pipeline + + +@admin.register(Pipeline) +class PipelineAdmin(admin.ModelAdmin): + list_display = ("name", "status") + actions = [ + "recompute_derived_field_a", + "recompute_derived_field_b", + "recompute_derived_field_c", + "rerun_pipeline_step_1", + "rerun_pipeline_step_2", + "rerun_pipeline_step_3", + "invalidate_downstream_cache", + "mark_as_reviewed_by_operator", + "mark_as_pending_operator_review", + "export_selected_rows_as_csv", + "export_selected_rows_as_json", + "notify_owner_of_selected_rows", + ] + + # --- 12 batch actions (changelist dropdown + detail button) ----------- # + + @admin.action(description="Recompute Derived Field A") + def recompute_derived_field_a(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Recomputed Derived Field A.", level=messages.SUCCESS) + + @admin.action(description="Recompute Derived Field B") + def recompute_derived_field_b(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Recomputed Derived Field B.", level=messages.SUCCESS) + + @admin.action(description="Recompute Derived Field C") + def recompute_derived_field_c(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Recomputed Derived Field C.", level=messages.SUCCESS) + + @admin.action(description="Re-run Pipeline Step 1") + def rerun_pipeline_step_1(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Re-ran Pipeline Step 1.", level=messages.SUCCESS) + + @admin.action(description="Re-run Pipeline Step 2") + def rerun_pipeline_step_2(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Re-ran Pipeline Step 2.", level=messages.SUCCESS) + + @admin.action(description="Re-run Pipeline Step 3") + def rerun_pipeline_step_3(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Re-ran Pipeline Step 3.", level=messages.SUCCESS) + + @admin.action(description="Invalidate Downstream Cache") + def invalidate_downstream_cache(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Invalidated downstream cache.", level=messages.SUCCESS) + + @admin.action(description="Mark As Reviewed By Operator") + def mark_as_reviewed_by_operator(self, request, queryset): # noqa: ANN001 + queryset.update(status="reviewed") + + @admin.action(description="Mark As Pending Operator Review") + def mark_as_pending_operator_review(self, request, queryset): # noqa: ANN001 + queryset.update(status="pending_review") + + @admin.action(description="Export Selected Rows As CSV") + def export_selected_rows_as_csv(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Exported as CSV.", level=messages.SUCCESS) + + @admin.action(description="Export Selected Rows As JSON") + def export_selected_rows_as_json(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Exported as JSON.", level=messages.SUCCESS) + + @admin.action(description="Notify Owner Of Selected Rows") + def notify_owner_of_selected_rows(self, request, queryset): # noqa: ANN001 + self.message_user(request, "Notified owners.", level=messages.SUCCESS) + + # --- 2 detail-only actions (detail button only) ----------------------- # + # `obj_id` / `str`-shaped third parameter → classifier marks `detail`. + + @admin.action(description="Open Detailed Audit View For This Pipeline Run") + def open_detailed_audit_view(self, request, obj_id: str): # noqa: ANN001 + self.message_user(request, f"Opened audit view for {obj_id}.", level=messages.INFO) + + @admin.action(description="Replay Last Operation On This Pipeline Run") + def replay_last_operation(self, request, obj_id: str): # noqa: ANN001 + self.message_user(request, f"Replayed last operation on {obj_id}.", level=messages.INFO) diff --git a/examples/many_actions/apps.py b/examples/many_actions/apps.py new file mode 100644 index 00000000..d83fad03 --- /dev/null +++ b/examples/many_actions/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ManyActionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "examples.many_actions" + label = "many_actions" + verbose_name = "Many Actions" diff --git a/examples/many_actions/migrations/0001_initial.py b/examples/many_actions/migrations/0001_initial.py new file mode 100644 index 00000000..1fd39eb5 --- /dev/null +++ b/examples/many_actions/migrations/0001_initial.py @@ -0,0 +1,26 @@ +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + initial = True + dependencies: list = [] + + operations = [ + migrations.CreateModel( + name="Pipeline", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("status", models.CharField(default="idle", max_length=32)), + ], + ), + ] diff --git a/examples/many_actions/migrations/__init__.py b/examples/many_actions/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/many_actions/models.py b/examples/many_actions/models.py new file mode 100644 index 00000000..0d91e366 --- /dev/null +++ b/examples/many_actions/models.py @@ -0,0 +1,19 @@ +from django.db import models + + +class Pipeline(models.Model): + """A ``Job``-style model whose admin declares a deliberately large set of + actions (12 batch + 2 detail-only) so the detail-page toolbar is forced to + wrap onto multiple lines — the regression fixture for #672. + + A single row of 14 buttons (several with long descriptions) is visibly + impossible at any reasonable viewport, so the SPA's stacked-header + + ``flex-wrap`` toolbar layout has to reflow them rather than push the title + or breadcrumb off-screen. + """ + + name = models.CharField(max_length=255) + status = models.CharField(max_length=32, default="idle") + + def __str__(self) -> str: + return self.name diff --git a/examples/project/settings.py b/examples/project/settings.py index 5caaa030..045e3d33 100644 --- a/examples/project/settings.py +++ b/examples/project/settings.py @@ -46,6 +46,9 @@ # Custom-form fixture: a ModelAdmin with a request-driven custom view + # custom template, proving the legacy-iframe escape hatch (#659). "examples.jobs", + # Many-actions fixture: a ModelAdmin with 12 batch + 2 detail-only + # actions, pinning the detail-page toolbar wrapping behaviour (#672). + "examples.many_actions", ] MIDDLEWARE = [ diff --git a/frontend/apps/web/src/Layout.tsx b/frontend/apps/web/src/Layout.tsx index 8e92e038..e99cbf93 100644 --- a/frontend/apps/web/src/Layout.tsx +++ b/frontend/apps/web/src/Layout.tsx @@ -15,8 +15,19 @@ export function Layout({ children }: PropsWithChildren) {
{/* Content. Extra top padding on mobile + tablet clears the fixed - top bar (shown until lg). */} -
+ top bar (shown until lg). + + `min-w-0` is load-bearing (#672): a flex item defaults to + `min-width: auto`, so `main` refuses to shrink below the + intrinsic width of its widest content. A detail-page toolbar + with 12+ action buttons makes that intrinsic width exceed the + viewport, so `flex-1` blew `main` PAST the viewport edge and + dragged the whole content column — title and breadcrumb + included — off-screen, no matter how the header rows were + stacked. `min-w-0` lets `main` shrink to the available width so + the toolbar's `flex-wrap` can actually wrap instead of + overflowing horizontally. */} +
{children}
diff --git a/frontend/apps/web/src/pages/DetailPage.test.tsx b/frontend/apps/web/src/pages/DetailPage.test.tsx index 2bfbebc8..e0732fc8 100644 --- a/frontend/apps/web/src/pages/DetailPage.test.tsx +++ b/frontend/apps/web/src/pages/DetailPage.test.tsx @@ -1,14 +1,16 @@ import '@testing-library/jest-dom/vitest'; import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, 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 { +function detail( + overrides: Partial = {}, +): DetailResponse { return { app_label: 'auth', model_name: 'group', @@ -21,15 +23,44 @@ function detail(): DetailResponse { object_actions: [], custom_views: [], save_options: { show_save: true }, + ...overrides, } as unknown as DetailResponse; } +// The #672 many-actions fixture mirrored on the client: 12 batch + +// 2 detail-only object actions with long descriptions. This is what +// `examples/many_actions` (PipelineAdmin) surfaces to the SPA. +const MANY_ACTIONS = [ + 'Recompute Derived Field A', + 'Recompute Derived Field B', + 'Recompute Derived Field C', + 'Re-run Pipeline Step 1', + 'Re-run Pipeline Step 2', + 'Re-run Pipeline Step 3', + 'Invalidate Downstream Cache', + 'Mark As Reviewed By Operator', + 'Mark As Pending Operator Review', + 'Export Selected Rows As CSV', + 'Export Selected Rows As JSON', + 'Notify Owner Of Selected Rows', + 'Open Detailed Audit View For This Pipeline Run', + 'Replay Last Operation On This Pipeline Run', +].map((label, i) => ({ + name: `action_${i}`, + label, + description: label, + target: i < 12 ? 'batch' : 'detail', +})); + +// Mutable per-test detail payload the mocked useDetail returns. +let detailState: DetailResponse = detail(); + vi.mock('@dar/data', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useApiClient: () => ({}), - useDetail: () => ({ data: detail(), loading: false, error: null, refresh: async () => {} }), + useDetail: () => ({ data: detailState, loading: false, error: null, refresh: async () => {} }), }; }); @@ -45,6 +76,10 @@ vi.mock('../toast', () => ({ // Import AFTER the mocks so DetailPage picks them up. const { DetailPage } = await import('./DetailPage'); +afterEach(() => { + detailState = detail(); +}); + function renderPage() { return render( @@ -84,3 +119,79 @@ describe('DetailPage header (#658 regression guard)', () => { expect(cluster).not.toBeNull(); }); }); + +describe('DetailPage many-actions toolbar (#672 regression guard)', () => { + // jsdom has no layout engine, so we can't assert pixel overflow. Instead we + // pin the CSS contract that makes wrapping (not horizontal overflow) + // structurally inevitable when a ModelAdmin surfaces 12 batch + 2 + // detail-only actions (the examples/many_actions PipelineAdmin fixture). + + it('renders all 14 object-action buttons plus the Edit/Delete cluster', () => { + detailState = detail({ object_actions: MANY_ACTIONS as never }); + renderPage(); + + for (const action of MANY_ACTIONS) { + expect(screen.getByRole('button', { name: action.label })).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); + + it('lays the toolbar out as a full-width wrapping row, separate from title/breadcrumb', () => { + detailState = detail({ object_actions: MANY_ACTIONS as never }); + renderPage(); + + const firstAction = screen.getByRole('button', { name: MANY_ACTIONS[0]!.label }); + // The toolbar row is the flex-wrap container that holds the actions. + const toolbar = firstAction.closest('div.flex.flex-wrap'); + expect(toolbar).not.toBeNull(); + // Full width + min-w-0 so it shrinks to the viewport and `flex-wrap` + // reflows the buttons instead of overflowing horizontally (#672). + expect(toolbar?.className).toContain('w-full'); + expect(toolbar?.className).toContain('min-w-0'); + expect(toolbar?.className).toContain('flex-wrap'); + + // The toolbar is a sibling row UNDER the H1 — it never shares the H1's + // horizontal space (the off-screen-title regression). + const header = toolbar?.closest('header'); + const title = screen.getByRole('heading', { level: 1 }); + expect(header).not.toBeNull(); + expect(header?.contains(title)).toBe(true); + expect(title.contains(toolbar as Node)).toBe(false); + expect(toolbar?.contains(title)).toBe(false); + }); + + it('keeps Edit/Delete right-aligned (ml-auto) on the last line after all 14 actions', () => { + 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'); + 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); + }); + + it('lets long action labels wrap inside the button (no wide min-content box)', () => { + detailState = detail({ object_actions: MANY_ACTIONS as never }); + renderPage(); + + // The longest detail-only label must not force a nowrap min-content width. + const longest = screen.getByRole('button', { + name: 'Open Detailed Audit View For This Pipeline Run', + }); + expect(longest.className).toContain('whitespace-normal'); + expect(longest.className).toContain('break-words'); + }); +}); diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index ca862297..2f59e7d8 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -140,7 +140,12 @@ export function DetailPage({ multi-word titles too. */}

{data.label}

{!editing && ( -
+ // Toolbar row (#658/#672): full-width (`w-full`) so it never + // shares horizontal space with the title/breadcrumb rows, and + // `flex-wrap` so 8–14 actions reflow onto further lines rather + // than overflowing. `min-w-0` lets the row shrink inside its + // flex ancestor so wrapping engages instead of pushing width. +