Skip to content

Commit 833bf4b

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): in-shell html-fragment rendering for custom change_form_template (drop iframe) + dep ^1.7.0 + 1.12.0 (#681)
* test(examples): exercise html-fragment path in jobs custom-form fixture (#679) The examples/jobs JobAdmin already ships the custom-template Path B (?run_custom=1 dual-listbox change_view + run_custom.html + the run_with_custom_steps action). Update its docstrings/comments from the old legacy-iframe contract to the server-rendered html-fragment contract (rest-api 1.7.0+, #75), and add JobHtmlFragmentApiTests covering the SPA's actual API path end-to-end against the example backend: - GET .../form-spec/?run_custom=1 -> renderer "html-fragment" (dual-listbox markup + inline <script> + csrf_token + submit_url preserved); - POST .../change/?run_custom=1 with no selection -> re-rendered html-fragment carrying the error message (PRG-to-self); - POST with a selection -> renderer "redirect", target mapped onto the SPA prefix, success message carried for toasting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(spa): in-shell html-fragment rendering for custom change_form_template, drop iframe (#679) Replace the legacy-iframe escape hatch with the server-rendered html-fragment renderer (rest-api 1.7.0+, #75). A custom change_form_template / change_view admin now renders INSIDE the SPA shell — no iframe, so no X-Frame-Options / SameSite / cross-origin cookie failure mode ever again. ChangeForm: renderer "html-fragment" -> new HtmlFragment component injects the content HTML into the SPA shell (breadcrumb / sidebar / title / toolbar stay React-rendered). Because dangerouslySetInnerHTML leaves parsed <script> elements inert, HtmlFragment clones each injected <script> into a fresh element and re-inserts it so it executes (required for the dual-listbox JS). The injected <form>'s submit is wired to ApiClient.submitChangeFragment, which POSTs the FormData to the round-trip route with credentials + X-CSRFToken from the fragment. On response: another html-fragment re-injects in place (validation errors, no SPA route change); { renderer: "redirect", to } triggers an SPA navigate(to) (never window.location); messages[] surface as toasts via the shared toastMessages adapter. The backend HTML is trusted (the integrator's own admin template behind the same auth) and injected verbatim — the trust boundary is documented in HtmlFragment.tsx. Removed the iframe path entirely: LegacyIframe(.test), legacy-url(.test) and the #673 framing-refusal workaround. contract.ts drops LegacyIframeResponse / "legacy-iframe" and adds HtmlFragmentResponse + RedirectResponse (+ the ChangePostPayload union + FragmentMessage); @dar/data re-exports updated. No regression for ModelAdmins using only documented hooks (form / fieldsets / formfield_overrides / get_form) — those keep rendering via the JSON field-map path. Bump django-admin-rest-api dep to ^1.7.0; version 1.11.2 -> 1.12.0; CHANGELOG [1.12.0] section; README "Embedding the legacy admin in an iframe" section rewritten as "Custom change_form_template admins — rendered in-shell". Closes #679 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 37508c2 commit 833bf4b

18 files changed

Lines changed: 673 additions & 444 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [1.12.0] — 2026-06-02
11+
12+
### Added
13+
- **Custom `change_form_template` admins now render INSIDE the SPA shell via a
14+
server-rendered html-fragment — no iframe (#679).** The form-spec endpoint
15+
(rest-api 1.7.0+, #75) renders a custom-template admin server-side, strips
16+
the admin chrome, and returns `{renderer: "html-fragment", html, csrf_token,
17+
submit_url, method, messages}`. The SPA injects that HTML into the content
18+
area while the breadcrumb / sidebar / title / toolbar stay React-rendered.
19+
Inline `<script>` / `<style>` the integrator's template emits **execute /
20+
apply after injection**: `dangerouslySetInnerHTML` leaves parsed `<script>`
21+
elements inert, so the new `HtmlFragment` component clones each into a fresh
22+
`<script>` and re-inserts it (the dual-listbox JS only runs because of this).
23+
The injected `<form>` POSTs via `fetch(…, {credentials: "include", headers:
24+
{"X-CSRFToken": …}})` to the round-trip route; a returned `html-fragment`
25+
re-injects in place (validation errors, no SPA route change), a
26+
`{renderer: "redirect", to}` triggers an SPA `navigate(to)` (never a
27+
`window.location` reload), and any Django `messages` surface as toasts.
28+
The backend HTML is **trusted** — it is the integrator's own admin template,
29+
rendered behind the same auth as `/admin/` over the same-origin API — so the
30+
custom JS/CSS is injected verbatim, deliberately not sanitised (see the
31+
`HtmlFragment.tsx` trust-boundary note). ModelAdmins that use only documented
32+
hooks (`form` / `fieldsets` / `formfield_overrides` / `get_form`) are
33+
unaffected — they keep rendering via the JSON field-map path. The
34+
`examples/jobs` `?run_custom=1` dual-listbox fixture exercises the full path
35+
(form-spec → POST → validation re-render → redirect) end-to-end against the
36+
example backend.
37+
38+
### Removed
39+
- **The `legacy-iframe` renderer and its iframe fallback are gone (#679).**
40+
No iframe element is ever rendered for a custom-template form again. This
41+
drops `LegacyIframe` (and the #673 "detect framing refusal → open-in-new-tab"
42+
workaround) and the `safeLegacyUrl` same-origin validation that only guarded
43+
the iframe `src`. Custom-template admins now render in-shell via the
44+
html-fragment renderer above — no `X-Frame-Options`, `SameSite=None; Secure`,
45+
or cross-origin cookie bridge required. The contract type drops
46+
`LegacyIframeResponse` / `renderer: "legacy-iframe"` and adds
47+
`HtmlFragmentResponse` (`renderer: "html-fragment"`) + `RedirectResponse`
48+
(`renderer: "redirect"`).
49+
50+
### Changed
51+
- **`django-admin-rest-api` floor raised to `^1.7.0` (#679).** 1.7.0 ships the
52+
html-fragment form-spec renderer (`renderer: "html-fragment"`) plus the POST
53+
round-trip route the in-shell custom form submits to. The SPA still degrades
54+
gracefully to the JSON form-spec / detail-driven form on an older backend.
55+
1056
## [1.11.2] — 2026-06-02
1157

1258
### Fixed

README.md

Lines changed: 36 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,7 @@ issues link the work to close each gap.
651651

652652
| Stock-Django hook | SPA behaviour | Tracked |
653653
|---|---|---|
654-
| `change_form_template` / `add_form_template` overrides | **Embedded in an iframe** (since 1.9.0, #659): the change/add form-spec endpoint returns a `legacy-iframe` pointer and the SPA embeds the legacy admin page inside the SPA shell (breadcrumb / sidebar / toolbar stay SPA-rendered). Port the form to documented ModelAdmin hooks at your own pace. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
654+
| `change_form_template` / `add_form_template` overrides | **Rendered server-side as an html-fragment, in-shell** (since 1.12.0, #679): the change/add form-spec endpoint renders the custom template server-side, strips the admin chrome, and returns `{renderer: "html-fragment", html, …}`; the SPA injects it into the content area while the breadcrumb / sidebar / title / toolbar stay React-rendered. The injected form's inline `<script>` / `<style>` run, its submit round-trips through the API (validation re-render / redirect / `messages` toasts), and **no iframe is used** — so no `X-Frame-Options` / `SameSite` configuration is required. Port the form to documented ModelAdmin hooks at your own pace. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
655655
| `change_list_template` / `change_password_template` / `object_history_template` overrides | Silently ignored — those surfaces render entirely from the JSON wire. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
656656
| `formfield_overrides = {Field: {"widget": CustomWidget}}` | Custom widget rendered via the React widget-registration API (`registerFieldWidget`, #625) when the consumer registers a renderer for the widget class; otherwise falls back to the default control + an operator-visible "not registered" note. | [#625](https://github.com/MartinCastroAlvarez/django-admin-react/issues/625) |
657657
| `empty_value_display` | **Hard-coded to ``.** A per-`ModelAdmin` / per-field `empty_value_display` override is **not** surfaced — the SPA renders the literal em-dash for every empty value, regardless of the consumer's chosen placeholder. | [#629](https://github.com/MartinCastroAlvarez/django-admin-react/issues/629) |
@@ -667,56 +667,43 @@ typical workaround is to keep that model on the legacy
667667
[experience-toggle strip](#experience-toggle-strip-optional) — the
668668
SPA + legacy admin happily coexist.
669669

670-
#### Embedding the legacy admin in an iframe — required backend headers
671-
672-
When a `ModelAdmin` overrides `change_form_template` / `add_form_template`,
673-
the SPA embeds the legacy admin page in an `<iframe>` (the row above). For
674-
the browser to actually render that frame, the **legacy admin responses must
675-
allow being framed by the SPA's origin** — otherwise the embed is refused and
676-
the SPA shows a "Embedding refused by the legacy admin — open in new tab"
677-
fallback (never a broken-image icon, #673).
678-
679-
Most projects mount `django.middleware.clickjacking.XFrameOptionsMiddleware`,
680-
which by default sets `X-Frame-Options: DENY` on **every** response and blocks
681-
the iframe. Configure the legacy responses as follows:
682-
683-
**Same origin (SPA and legacy admin under one host — the common case):**
684-
685-
```python
686-
# settings.py
687-
X_FRAME_OPTIONS = "SAMEORIGIN" # was the implicit "DENY"
688-
# …or drop XFrameOptionsMiddleware entirely if you don't need clickjacking
689-
# protection on the legacy surface.
690-
```
691-
692-
`SAMEORIGIN` lets the same-origin SPA frame the legacy page while still
693-
blocking cross-site framing.
694-
695-
**Cross-origin (SPA and legacy admin on different origins):**
696-
697-
```python
698-
# On the legacy admin responses (e.g. via a middleware or
699-
# django-csp), allow ONLY the SPA origin to frame them:
700-
# Content-Security-Policy: frame-ancestors https://admin.example.com
701-
#
702-
# And, because the iframe is a cross-site context, the legacy session
703-
# cookie must be sent in it:
704-
# settings.py
705-
SESSION_COOKIE_SAMESITE = "None" # send the cookie in the cross-site frame
706-
SESSION_COOKIE_SECURE = True # required whenever SameSite=None
670+
#### Custom `change_form_template` admins — rendered in-shell (no iframe)
671+
672+
When a `ModelAdmin` overrides `change_form_template` / `add_form_template`
673+
(or a `change_view` override renders a non-standard template — e.g. a
674+
`?run_custom=1` branch), the JSON form-spec can't reproduce the form. Since
675+
1.12.0 (#679) the form-spec endpoint (rest-api 1.7.0+) renders the admin's
676+
real view **server-side**, strips the admin chrome, and returns the content
677+
HTML for the SPA to inject inside its own shell:
678+
679+
```json
680+
{
681+
"renderer": "html-fragment",
682+
"html": "<form …>…</form>",
683+
"csrf_token": "",
684+
"submit_url": "/admin/<app>/<model>/<pk>/change/?<qs>",
685+
"method": "POST",
686+
"messages": [{ "level": "success", "text": "" }]
687+
}
707688
```
708689

709-
Without these, the framed legacy page either refuses to load (`frame-ancestors`
710-
block) or loads unauthenticated (cookie dropped) and bounces through a login
711-
redirect the browser then refuses to display. The
712-
[`examples/jobs`](examples/jobs) `JobAdmin` (the `?run_custom=1` variant)
713-
exercises this path end-to-end against the example backend.
714-
715-
> The SPA detects a refused frame client-side (a `loading → loaded → refused`
716-
> state machine: a ~4s window with no iframe `load` event ⇒ refused) and
717-
> renders the fallback. A future server-side `legacy_iframeable` flag computed
718-
> from the response middleware chain (cross-repo, rest-api) could switch to the
719-
> "open in new tab only" UI immediately — tracked as a follow-up.
690+
The SPA injects `html` into the content area (breadcrumb / sidebar / title /
691+
toolbar stay React-rendered), **re-executes** the template's inline
692+
`<script>` (so custom-widget JS / dual-listbox handlers / drag-and-drop run),
693+
and wires the injected `<form>` to POST back through the API round-trip route
694+
(`credentials: "include"` + `X-CSRFToken`). On the response: another
695+
`html-fragment` re-injects in place (validation errors), a
696+
`{renderer: "redirect", to}` triggers an SPA `navigate(to)`, and any Django
697+
`messages` surface as toasts.
698+
699+
This needs **no iframe**, so there is nothing to configure: no
700+
`X-Frame-Options`, no `Content-Security-Policy: frame-ancestors`, no
701+
`SESSION_COOKIE_SAMESITE = "None"` cross-origin cookie bridge. The fragment is
702+
same-origin and trusted (it is your own admin template, rendered behind the
703+
same auth as `/admin/`), so its custom JS/CSS is injected verbatim. The
704+
[`examples/jobs`](examples/jobs) `JobAdmin` (the `?run_custom=1` dual-listbox
705+
variant) exercises the full path — form-spec → POST → validation re-render →
706+
redirect — end-to-end against the example backend.
720707

721708
---
722709

examples/jobs/admin.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1-
"""``JobAdmin`` — the custom-form fixture that proves the legacy-iframe escape.
1+
"""``JobAdmin`` — the custom-form fixture that exercises the html-fragment path.
22
33
This admin uses *only* documented Django ``ModelAdmin`` hooks — no
44
django-admin-react / -rest-api / -mcp-specific API. The point: if this runs
5-
on the React admin at ``/admin-react/`` with at most a single-view iframe
6-
fallback, then any legacy ``/admin/`` ModelAdmin does too.
5+
inside the React admin shell at ``/admin2/`` with the server-rendered
6+
html-fragment renderer, then any legacy ``/admin/`` ModelAdmin does too.
77
88
Two rendering paths:
99
10-
* **Path A** — ``/admin-react/jobs/job/<pk>/change/`` (no query). The stock
10+
* **Path A** — ``/admin2/jobs/job/<pk>/change/`` (no query). The stock
1111
change form, fully describable by the form-spec contract.
1212
``formfield_for_dbfield`` swaps ``metadata`` to a large textarea, which
1313
the SPA renders natively from ``widget.kind == "textarea"``.
1414
* **Path B** — ``?run_custom=1``. ``change_view`` returns a hand-rolled
1515
dual-listbox template (not a ModelForm, not fieldsets). The form-spec
16-
resolver detects the custom render and returns
17-
``{renderer: "legacy-iframe", legacy_url: …}``; the SPA embeds the legacy
18-
page in an iframe inside its own chrome.
16+
resolver (rest-api 1.7.0+, #75) detects the custom render, renders it
17+
SERVER-SIDE, strips the admin chrome, and returns
18+
``{renderer: "html-fragment", html, csrf_token, submit_url, method,
19+
messages}``. The SPA injects that HTML inside its own shell — breadcrumb /
20+
sidebar / title / toolbar stay React-rendered, no iframe (#679). The
21+
injected ``<form>`` POSTs back through the round-trip route, which
22+
re-renders on a validation error (``messages.error`` + redirect-to-self) or
23+
returns ``{renderer: "redirect", to}`` on success — surfaced as a SPA
24+
``navigate()`` plus toasts.
1925
"""
2026

2127
from __future__ import annotations

examples/jobs/templates/admin/jobs/job/run_custom.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44

55
This is a *pure custom template* with a custom POST contract: no ModelForm,
66
no fieldsets, no model fields. The form-spec resolver detects this (the
7-
overridden change_view renders a non-standard template) and tells the React
8-
SPA to embed THIS legacy page in an iframe. The contract that must hold:
7+
overridden change_view renders a non-standard template), renders it
8+
server-side, strips the admin chrome, and hands the SPA the content-block
9+
HTML — including the inline <script>/<style> below to inject INSIDE its own
10+
shell (no iframe, #679). The inline JS must survive into the SPA and run after
11+
injection (the SPA re-executes injected <script> elements). The contract that
12+
must hold:
913

1014
request.POST.getlist("selected_steps") ordered, in DOM order of the
1115
right-hand column.

examples/jobs/tests/test_admin.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Backend tests for the Job custom-form fixture.
22
33
Exercises the legacy ``/admin/`` side of the contract — the side the React
4-
SPA embeds in an iframe for Path B. Pure Django ``TestCase`` + test client;
5-
no browser / e2e.
4+
SPA renders server-side as an html-fragment for Path B (#679). Pure Django
5+
``TestCase`` + test client; no browser / e2e.
66
"""
77

88
from django.contrib import admin
@@ -66,3 +66,77 @@ def test_path_b_post_empty_is_rejected(self) -> None:
6666
# Redirects back to the same custom view with an error message.
6767
self.assertEqual(resp.status_code, 302)
6868
self.assertIn("run_custom=1", resp["Location"])
69+
70+
71+
class JobHtmlFragmentApiTests(TestCase):
72+
"""End-to-end exercise of the SPA's html-fragment path (#679) against the
73+
example backend's REST API — the side the React SPA actually consumes.
74+
75+
The SPA fetches ``GET …/form-spec/?run_custom=1`` (→ ``html-fragment``)
76+
and POSTs the injected form back to ``POST …/<pk>/change/?run_custom=1``
77+
(→ another ``html-fragment`` on a validation error, or a ``redirect`` on
78+
success, with Django ``messages`` surfaced for SPA toasts). Pure Django
79+
``TestCase`` + test client; no browser / e2e.
80+
"""
81+
82+
@classmethod
83+
def setUpTestData(cls) -> None:
84+
cls.user = get_user_model().objects.create_superuser(
85+
username="root",
86+
email="root@example.com",
87+
password="x", # noqa: S106
88+
)
89+
cls.job = Job.objects.create(name="nightly", status="idle")
90+
91+
def setUp(self) -> None:
92+
self.client.force_login(self.user)
93+
94+
def _form_spec_url(self) -> str:
95+
return f"/admin-react/api/v1/jobs/job/{self.job.pk}/form-spec/?run_custom=1"
96+
97+
def _change_post_url(self) -> str:
98+
return f"/admin-react/api/v1/jobs/job/{self.job.pk}/change/?run_custom=1"
99+
100+
def test_form_spec_returns_html_fragment(self) -> None:
101+
"""The custom-template view resolves to a server-rendered html-fragment
102+
(not the JSON form-spec, never an iframe)."""
103+
resp = self.client.get(self._form_spec_url())
104+
self.assertEqual(resp.status_code, 200)
105+
payload = resp.json()
106+
self.assertEqual(payload["renderer"], "html-fragment")
107+
# The dual-listbox markup + the inline JS survive into the fragment.
108+
self.assertIn('data-step="fetch"', payload["html"])
109+
self.assertIn("<script>", payload["html"])
110+
# The form wiring the SPA needs is present.
111+
self.assertTrue(payload["csrf_token"])
112+
self.assertEqual(payload["method"], "POST")
113+
self.assertIn("run_custom=1", payload["submit_url"])
114+
115+
def test_post_empty_re_renders_fragment_with_error(self) -> None:
116+
"""An empty selection re-renders the fragment (the PRG-to-self idiom)
117+
and carries the error message for the SPA to toast."""
118+
resp = self.client.post(self._change_post_url(), data={})
119+
self.assertEqual(resp.status_code, 200)
120+
payload = resp.json()
121+
self.assertEqual(payload["renderer"], "html-fragment")
122+
self.assertTrue(
123+
any(m["level"] == "error" for m in payload["messages"]),
124+
payload["messages"],
125+
)
126+
127+
def test_post_selection_redirects_to_spa(self) -> None:
128+
"""A valid selection redirects; the target is mapped onto the SPA prefix
129+
and the success message is carried for the SPA to toast."""
130+
resp = self.client.post(
131+
self._change_post_url(),
132+
data={"selected_steps": ["validate", "fetch"]},
133+
)
134+
self.assertEqual(resp.status_code, 200)
135+
payload = resp.json()
136+
self.assertEqual(payload["renderer"], "redirect")
137+
# Mapped onto the SPA prefix (not the legacy /admin/ mount).
138+
self.assertNotIn("/admin/", payload["to"])
139+
self.assertTrue(
140+
any("validate → fetch" in m["text"] for m in payload["messages"]),
141+
payload["messages"],
142+
)

frontend/apps/web/src/legacy-url.test.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.

0 commit comments

Comments
 (0)