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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.11.1] — 2026-06-02

### Fixed
- **Detail-page toolbar no longer pushes the title off-screen (#672).** The
header already stacked breadcrumb / title / toolbar as three full-width rows
(#658/#674), but a `ModelAdmin` with 8+ actions still overflowed
horizontally and dragged the H1 + breadcrumb out of view. Root cause was the
flexbox `min-width: auto` default on the content column: `<main>` is a flex
item, so it refused to shrink below the intrinsic width of the widest
toolbar and `flex-1` blew it past the viewport — no header re-stacking could
help. `<main>` now carries `min-w-0` so it shrinks to the viewport and the
toolbar's `flex-wrap` actually reflows the buttons; the toolbar row is
`w-full min-w-0`, long action labels wrap inside their button
(`whitespace-normal break-words`) instead of forming a wide min-content box,
and the Edit/Delete cluster stays right-aligned (`ml-auto`) on the last line
regardless of how many custom actions exist. New `examples/many_actions`
`PipelineAdmin` fixture (12 batch + 2 detail-only actions) plus
`DetailPage.test.tsx` guards pin the wrapping behaviour.
- **Legacy-iframe shows a clear fallback instead of a broken-image icon
(#673).** 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
`error` event fired. `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 (keeping the proven-working
Open-in-new-tab button and the #665 same-origin validation + `sandbox`).
README now documents the required backend headers
(`X-Frame-Options: SAMEORIGIN` / removing `XFrameOptionsMiddleware`; for
cross-origin, `Content-Security-Policy: frame-ancestors <spa-origin>` plus
`SESSION_COOKIE_SAMESITE = "None"` + `SESSION_COOKIE_SECURE`), and the
`examples/jobs` `?run_custom=1` variant exercises the path end-to-end.

## [1.11.0] — 2026-06-02

### Added
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,57 @@ typical workaround is to keep that model on the legacy
[experience-toggle strip](#experience-toggle-strip-optional) — the
SPA + legacy admin happily coexist.

#### Embedding the legacy admin in an iframe — required backend headers

When a `ModelAdmin` overrides `change_form_template` / `add_form_template`,
the SPA embeds the legacy admin page in an `<iframe>` (the row above). For
the browser to actually render that frame, the **legacy admin responses must
allow being framed by the SPA's origin** — otherwise the embed is refused and
the SPA shows a "Embedding refused by the legacy admin — open in new tab"
fallback (never a broken-image icon, #673).

Most projects mount `django.middleware.clickjacking.XFrameOptionsMiddleware`,
which by default sets `X-Frame-Options: DENY` on **every** response and blocks
the iframe. Configure the legacy responses as follows:

**Same origin (SPA and legacy admin under one host — the common case):**

```python
# settings.py
X_FRAME_OPTIONS = "SAMEORIGIN" # was the implicit "DENY"
# …or drop XFrameOptionsMiddleware entirely if you don't need clickjacking
# protection on the legacy surface.
```

`SAMEORIGIN` lets the same-origin SPA frame the legacy page while still
blocking cross-site framing.

**Cross-origin (SPA and legacy admin on different origins):**

```python
# On the legacy admin responses (e.g. via a middleware or
# django-csp), allow ONLY the SPA origin to frame them:
# Content-Security-Policy: frame-ancestors https://admin.example.com
#
# And, because the iframe is a cross-site context, the legacy session
# cookie must be sent in it:
# settings.py
SESSION_COOKIE_SAMESITE = "None" # send the cookie in the cross-site frame
SESSION_COOKIE_SECURE = True # required whenever SameSite=None
```

Without these, the framed legacy page either refuses to load (`frame-ancestors`
block) or loads unauthenticated (cookie dropped) and bounces through a login
redirect the browser then refuses to display. The
[`examples/jobs`](examples/jobs) `JobAdmin` (the `?run_custom=1` variant)
exercises this path end-to-end against the example backend.

> The SPA detects a refused frame client-side (a `loading → loaded → refused`
> state machine: a ~4s window with no iframe `load` event ⇒ refused) and
> renders the fallback. A future server-side `legacy_iframeable` flag computed
> from the response middleware chain (cross-repo, rest-api) could switch to the
> "open in new tab only" UI immediately — tracked as a follow-up.

---

## Writing safe `list_display` callables
Expand Down
Empty file.
105 changes: 105 additions & 0 deletions examples/many_actions/admin.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions examples/many_actions/apps.py
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 26 additions & 0 deletions examples/many_actions/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
Empty file.
19 changes: 19 additions & 0 deletions examples/many_actions/models.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions examples/project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
15 changes: 13 additions & 2 deletions frontend/apps/web/src/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,19 @@ export function Layout({ children }: PropsWithChildren) {
<div className="flex h-full min-h-screen">
<Sidebar />
{/* Content. Extra top padding on mobile + tablet clears the fixed
top bar (shown until lg). */}
<main className="flex-1 overflow-y-auto p-6 pt-20 lg:pt-6">
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. */}
<main className="min-w-0 flex-1 overflow-y-auto p-6 pt-20 lg:pt-6">
<LegacyAdminBanner />
{children}
</main>
Expand Down
Loading
Loading