You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: README.md
+36-49Lines changed: 36 additions & 49 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -651,7 +651,7 @@ issues link the work to close each gap.
651
651
652
652
| Stock-Django hook | SPA behaviour | Tracked |
653
653
|---|---|---|
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)|
655
655
|`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)|
656
656
|`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)|
657
657
|`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
667
667
[experience-toggle strip](#experience-toggle-strip-optional) — the
668
668
SPA + legacy admin happily coexist.
669
669
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:
0 commit comments