Skip to content

Commit 3f4ef04

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(spa): detail-header toolbar overflow (#672) + legacy-iframe refused-fallback (#673) + 1.11.1 (#676)
* fix(spa): detail-header toolbar overflow — main min-w-0 + wrapping toolbar (#672) 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 `<main>` 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 `<main>` lets it shrink to the viewport so the toolbar's `flex-wrap` actually reflows. - Layout: `<main>` 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) <noreply@anthropic.com> * fix(spa): legacy-iframe refused-fallback instead of broken-image (#673) + 1.11.1 When the legacy admin refuses to be framed (Django's XFrameOptionsMiddleware sends `X-Frame-Options: DENY`, or a cross-origin `frame-ancestors` block), the browser painted its broken-image glyph and no reliable `error` event fired on the iframe. - LegacyIframe now runs a `loading → loaded → refused` state machine: `onLoad` marks the frame loaded; a ~4s timeout with no `onLoad` marks it `refused` and swaps in an explicit "Embedding refused by the legacy admin — open in new tab" fallback (keeps the proven-working Open-in-new-tab button and the #665 same-origin validation + sandbox). - LegacyIframe.test.tsx: covers same src as the link, loaded (no flip), refused→fallback on timeout, no-flip-before-timeout, and off-origin rejection (with fake timers). - README: documents required backend headers — `X-Frame-Options: SAMEORIGIN` (or removing XFrameOptionsMiddleware); cross-origin `Content-Security-Policy: frame-ancestors <spa-origin>` plus `SESSION_COOKIE_SAMESITE = "None"` + `SESSION_COOKIE_SECURE`. The examples/jobs `?run_custom=1` variant exercises the path end-to-end. - Bump 1.11.0 → 1.11.1 + CHANGELOG [1.11.1] (Fixed: #672, #673). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent daea792 commit 3f4ef04

16 files changed

Lines changed: 546 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.11.1] — 2026-06-02
11+
12+
### Fixed
13+
- **Detail-page toolbar no longer pushes the title off-screen (#672).** The
14+
header already stacked breadcrumb / title / toolbar as three full-width rows
15+
(#658/#674), but a `ModelAdmin` with 8+ actions still overflowed
16+
horizontally and dragged the H1 + breadcrumb out of view. Root cause was the
17+
flexbox `min-width: auto` default on the content column: `<main>` is a flex
18+
item, so it refused to shrink below the intrinsic width of the widest
19+
toolbar and `flex-1` blew it past the viewport — no header re-stacking could
20+
help. `<main>` now carries `min-w-0` so it shrinks to the viewport and the
21+
toolbar's `flex-wrap` actually reflows the buttons; the toolbar row is
22+
`w-full min-w-0`, long action labels wrap inside their button
23+
(`whitespace-normal break-words`) instead of forming a wide min-content box,
24+
and the Edit/Delete cluster stays right-aligned (`ml-auto`) on the last line
25+
regardless of how many custom actions exist. New `examples/many_actions`
26+
`PipelineAdmin` fixture (12 batch + 2 detail-only actions) plus
27+
`DetailPage.test.tsx` guards pin the wrapping behaviour.
28+
- **Legacy-iframe shows a clear fallback instead of a broken-image icon
29+
(#673).** When the legacy admin refuses to be framed (Django's
30+
`XFrameOptionsMiddleware` sends `X-Frame-Options: DENY`, or a cross-origin
31+
`frame-ancestors` block), the browser painted its broken-image glyph and no
32+
`error` event fired. `LegacyIframe` now runs a `loading → loaded → refused`
33+
state machine — `onLoad` marks the frame loaded; a ~4s timeout with no
34+
`onLoad` marks it `refused` and swaps in an explicit "Embedding refused by
35+
the legacy admin — open in new tab" fallback (keeping the proven-working
36+
Open-in-new-tab button and the #665 same-origin validation + `sandbox`).
37+
README now documents the required backend headers
38+
(`X-Frame-Options: SAMEORIGIN` / removing `XFrameOptionsMiddleware`; for
39+
cross-origin, `Content-Security-Policy: frame-ancestors <spa-origin>` plus
40+
`SESSION_COOKIE_SAMESITE = "None"` + `SESSION_COOKIE_SECURE`), and the
41+
`examples/jobs` `?run_custom=1` variant exercises the path end-to-end.
42+
1043
## [1.11.0] — 2026-06-02
1144

1245
### Added

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,57 @@ typical workaround is to keep that model on the legacy
663663
[experience-toggle strip](#experience-toggle-strip-optional) — the
664664
SPA + legacy admin happily coexist.
665665

666+
#### Embedding the legacy admin in an iframe — required backend headers
667+
668+
When a `ModelAdmin` overrides `change_form_template` / `add_form_template`,
669+
the SPA embeds the legacy admin page in an `<iframe>` (the row above). For
670+
the browser to actually render that frame, the **legacy admin responses must
671+
allow being framed by the SPA's origin** — otherwise the embed is refused and
672+
the SPA shows a "Embedding refused by the legacy admin — open in new tab"
673+
fallback (never a broken-image icon, #673).
674+
675+
Most projects mount `django.middleware.clickjacking.XFrameOptionsMiddleware`,
676+
which by default sets `X-Frame-Options: DENY` on **every** response and blocks
677+
the iframe. Configure the legacy responses as follows:
678+
679+
**Same origin (SPA and legacy admin under one host — the common case):**
680+
681+
```python
682+
# settings.py
683+
X_FRAME_OPTIONS = "SAMEORIGIN" # was the implicit "DENY"
684+
# …or drop XFrameOptionsMiddleware entirely if you don't need clickjacking
685+
# protection on the legacy surface.
686+
```
687+
688+
`SAMEORIGIN` lets the same-origin SPA frame the legacy page while still
689+
blocking cross-site framing.
690+
691+
**Cross-origin (SPA and legacy admin on different origins):**
692+
693+
```python
694+
# On the legacy admin responses (e.g. via a middleware or
695+
# django-csp), allow ONLY the SPA origin to frame them:
696+
# Content-Security-Policy: frame-ancestors https://admin.example.com
697+
#
698+
# And, because the iframe is a cross-site context, the legacy session
699+
# cookie must be sent in it:
700+
# settings.py
701+
SESSION_COOKIE_SAMESITE = "None" # send the cookie in the cross-site frame
702+
SESSION_COOKIE_SECURE = True # required whenever SameSite=None
703+
```
704+
705+
Without these, the framed legacy page either refuses to load (`frame-ancestors`
706+
block) or loads unauthenticated (cookie dropped) and bounces through a login
707+
redirect the browser then refuses to display. The
708+
[`examples/jobs`](examples/jobs) `JobAdmin` (the `?run_custom=1` variant)
709+
exercises this path end-to-end against the example backend.
710+
711+
> The SPA detects a refused frame client-side (a `loading → loaded → refused`
712+
> state machine: a ~4s window with no iframe `load` event ⇒ refused) and
713+
> renders the fallback. A future server-side `legacy_iframeable` flag computed
714+
> from the response middleware chain (cross-repo, rest-api) could switch to the
715+
> "open in new tab only" UI immediately — tracked as a follow-up.
716+
666717
---
667718

668719
## Writing safe `list_display` callables

examples/many_actions/__init__.py

Whitespace-only changes.

examples/many_actions/admin.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""``PipelineAdmin`` — the many-actions fixture that pins the detail-page
2+
toolbar wrapping behaviour (#672).
3+
4+
The point of this admin is purely the *number* and *width* of its actions.
5+
Stock Django ``@admin.action`` declarations only:
6+
7+
* **12 batch actions** — default (queryset-shaped) third parameter, so the API
8+
signature classifier marks them ``target: "batch"``. They render BOTH in the
9+
changelist multi-select dropdown AND as buttons on the detail page.
10+
* **2 detail-only actions** — ``obj_id`` / ``str``-shaped third parameter, so
11+
the classifier marks them ``target: "detail"``. They render ONLY as detail
12+
buttons, never in the changelist dropdown.
13+
14+
14 buttons (several with long descriptions) on one detail page cannot fit on a
15+
single row at 1280/1024/768/480 px. Before #672 the toolbar overflowed
16+
horizontally and pushed the H1 title + breadcrumb off-screen; now the SPA
17+
stacks the header as three full-width rows and ``flex-wrap``s the toolbar.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from django.contrib import admin
23+
from django.contrib import messages
24+
25+
from examples.many_actions.models import Pipeline
26+
27+
28+
@admin.register(Pipeline)
29+
class PipelineAdmin(admin.ModelAdmin):
30+
list_display = ("name", "status")
31+
actions = [
32+
"recompute_derived_field_a",
33+
"recompute_derived_field_b",
34+
"recompute_derived_field_c",
35+
"rerun_pipeline_step_1",
36+
"rerun_pipeline_step_2",
37+
"rerun_pipeline_step_3",
38+
"invalidate_downstream_cache",
39+
"mark_as_reviewed_by_operator",
40+
"mark_as_pending_operator_review",
41+
"export_selected_rows_as_csv",
42+
"export_selected_rows_as_json",
43+
"notify_owner_of_selected_rows",
44+
]
45+
46+
# --- 12 batch actions (changelist dropdown + detail button) ----------- #
47+
48+
@admin.action(description="Recompute Derived Field A")
49+
def recompute_derived_field_a(self, request, queryset): # noqa: ANN001
50+
self.message_user(request, "Recomputed Derived Field A.", level=messages.SUCCESS)
51+
52+
@admin.action(description="Recompute Derived Field B")
53+
def recompute_derived_field_b(self, request, queryset): # noqa: ANN001
54+
self.message_user(request, "Recomputed Derived Field B.", level=messages.SUCCESS)
55+
56+
@admin.action(description="Recompute Derived Field C")
57+
def recompute_derived_field_c(self, request, queryset): # noqa: ANN001
58+
self.message_user(request, "Recomputed Derived Field C.", level=messages.SUCCESS)
59+
60+
@admin.action(description="Re-run Pipeline Step 1")
61+
def rerun_pipeline_step_1(self, request, queryset): # noqa: ANN001
62+
self.message_user(request, "Re-ran Pipeline Step 1.", level=messages.SUCCESS)
63+
64+
@admin.action(description="Re-run Pipeline Step 2")
65+
def rerun_pipeline_step_2(self, request, queryset): # noqa: ANN001
66+
self.message_user(request, "Re-ran Pipeline Step 2.", level=messages.SUCCESS)
67+
68+
@admin.action(description="Re-run Pipeline Step 3")
69+
def rerun_pipeline_step_3(self, request, queryset): # noqa: ANN001
70+
self.message_user(request, "Re-ran Pipeline Step 3.", level=messages.SUCCESS)
71+
72+
@admin.action(description="Invalidate Downstream Cache")
73+
def invalidate_downstream_cache(self, request, queryset): # noqa: ANN001
74+
self.message_user(request, "Invalidated downstream cache.", level=messages.SUCCESS)
75+
76+
@admin.action(description="Mark As Reviewed By Operator")
77+
def mark_as_reviewed_by_operator(self, request, queryset): # noqa: ANN001
78+
queryset.update(status="reviewed")
79+
80+
@admin.action(description="Mark As Pending Operator Review")
81+
def mark_as_pending_operator_review(self, request, queryset): # noqa: ANN001
82+
queryset.update(status="pending_review")
83+
84+
@admin.action(description="Export Selected Rows As CSV")
85+
def export_selected_rows_as_csv(self, request, queryset): # noqa: ANN001
86+
self.message_user(request, "Exported as CSV.", level=messages.SUCCESS)
87+
88+
@admin.action(description="Export Selected Rows As JSON")
89+
def export_selected_rows_as_json(self, request, queryset): # noqa: ANN001
90+
self.message_user(request, "Exported as JSON.", level=messages.SUCCESS)
91+
92+
@admin.action(description="Notify Owner Of Selected Rows")
93+
def notify_owner_of_selected_rows(self, request, queryset): # noqa: ANN001
94+
self.message_user(request, "Notified owners.", level=messages.SUCCESS)
95+
96+
# --- 2 detail-only actions (detail button only) ----------------------- #
97+
# `obj_id` / `str`-shaped third parameter → classifier marks `detail`.
98+
99+
@admin.action(description="Open Detailed Audit View For This Pipeline Run")
100+
def open_detailed_audit_view(self, request, obj_id: str): # noqa: ANN001
101+
self.message_user(request, f"Opened audit view for {obj_id}.", level=messages.INFO)
102+
103+
@admin.action(description="Replay Last Operation On This Pipeline Run")
104+
def replay_last_operation(self, request, obj_id: str): # noqa: ANN001
105+
self.message_user(request, f"Replayed last operation on {obj_id}.", level=messages.INFO)

examples/many_actions/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ManyActionsConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "examples.many_actions"
7+
label = "many_actions"
8+
verbose_name = "Many Actions"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.db import migrations
2+
from django.db import models
3+
4+
5+
class Migration(migrations.Migration):
6+
initial = True
7+
dependencies: list = []
8+
9+
operations = [
10+
migrations.CreateModel(
11+
name="Pipeline",
12+
fields=[
13+
(
14+
"id",
15+
models.BigAutoField(
16+
auto_created=True,
17+
primary_key=True,
18+
serialize=False,
19+
verbose_name="ID",
20+
),
21+
),
22+
("name", models.CharField(max_length=255)),
23+
("status", models.CharField(default="idle", max_length=32)),
24+
],
25+
),
26+
]

examples/many_actions/migrations/__init__.py

Whitespace-only changes.

examples/many_actions/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from django.db import models
2+
3+
4+
class Pipeline(models.Model):
5+
"""A ``Job``-style model whose admin declares a deliberately large set of
6+
actions (12 batch + 2 detail-only) so the detail-page toolbar is forced to
7+
wrap onto multiple lines — the regression fixture for #672.
8+
9+
A single row of 14 buttons (several with long descriptions) is visibly
10+
impossible at any reasonable viewport, so the SPA's stacked-header +
11+
``flex-wrap`` toolbar layout has to reflow them rather than push the title
12+
or breadcrumb off-screen.
13+
"""
14+
15+
name = models.CharField(max_length=255)
16+
status = models.CharField(max_length=32, default="idle")
17+
18+
def __str__(self) -> str:
19+
return self.name

examples/project/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
# Custom-form fixture: a ModelAdmin with a request-driven custom view +
4747
# custom template, proving the legacy-iframe escape hatch (#659).
4848
"examples.jobs",
49+
# Many-actions fixture: a ModelAdmin with 12 batch + 2 detail-only
50+
# actions, pinning the detail-page toolbar wrapping behaviour (#672).
51+
"examples.many_actions",
4952
]
5053

5154
MIDDLEWARE = [

frontend/apps/web/src/Layout.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,19 @@ export function Layout({ children }: PropsWithChildren) {
1515
<div className="flex h-full min-h-screen">
1616
<Sidebar />
1717
{/* Content. Extra top padding on mobile + tablet clears the fixed
18-
top bar (shown until lg). */}
19-
<main className="flex-1 overflow-y-auto p-6 pt-20 lg:pt-6">
18+
top bar (shown until lg).
19+
20+
`min-w-0` is load-bearing (#672): a flex item defaults to
21+
`min-width: auto`, so `main` refuses to shrink below the
22+
intrinsic width of its widest content. A detail-page toolbar
23+
with 12+ action buttons makes that intrinsic width exceed the
24+
viewport, so `flex-1` blew `main` PAST the viewport edge and
25+
dragged the whole content column — title and breadcrumb
26+
included — off-screen, no matter how the header rows were
27+
stacked. `min-w-0` lets `main` shrink to the available width so
28+
the toolbar's `flex-wrap` can actually wrap instead of
29+
overflowing horizontally. */}
30+
<main className="min-w-0 flex-1 overflow-y-auto p-6 pt-20 lg:pt-6">
2031
<LegacyAdminBanner />
2132
{children}
2233
</main>

0 commit comments

Comments
 (0)