Skip to content

feat(spa): in-shell html-fragment rendering for custom change_form_template (drop iframe) + dep ^1.7.0 + 1.12.0#681

Merged
MartinCastroAlvarez merged 2 commits into
mainfrom
feat/html-fragment-renderer-679
Jun 2, 2026
Merged

feat(spa): in-shell html-fragment rendering for custom change_form_template (drop iframe) + dep ^1.7.0 + 1.12.0#681
MartinCastroAlvarez merged 2 commits into
mainfrom
feat/html-fragment-renderer-679

Conversation

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner

Summary

Custom change_form_template admins now render inside the SPA shell via a server-rendered html-fragment — no iframe. This drops the legacy-iframe escape hatch (and its X-Frame-Options / SameSite / broken-image failure modes) entirely.

Contract (rest-api 1.7.0+, #75)

GET …/form-spec/ returns, for a custom-template admin:

{ "renderer": "html-fragment", "html": "<form …>…</form>", "csrf_token": "",
  "submit_url": "/admin/<app>/<model>/<pk>/change/?<qs>", "method": "POST",
  "messages": [{ "level": "success", "text": "" }] }

The SPA POSTs the injected form back to POST <app>/<model>/<pk>/change/?<qs>, which returns another html-fragment (validation errors / PRG-to-self re-render) or { "renderer": "redirect", "to": "/admin2/…", "messages": […] } (success).

What the SPA does

  • Injects html into the content area; breadcrumb / sidebar / title / toolbar stay React-rendered.
  • Re-executes inline <script>: dangerouslySetInnerHTML leaves parsed <script> inert, so HtmlFragment clones each into a fresh <script> element and re-inserts it (required for the dual-listbox JS). <style> applies for free.
  • Wires submit to fetch(…, { method, credentials: "include", body: FormData, headers: { "X-CSRFToken": csrf_token } }) via ApiClient.submitChangeFragment. Response handling: html-fragment → re-inject in place; redirect → SPA navigate(to) (never window.location); messages[] → toasts via the shared toastMessages adapter.
  • Trust boundary (documented in HtmlFragment.tsx): the fragment is the integrator's own admin template, rendered behind the same auth as /admin/ over the same-origin API — injected verbatim, deliberately not sanitised.
  • No regression for ModelAdmins using only documented hooks (form / fieldsets / formfield_overrides / get_form) — those keep rendering via the JSON field-map path.

Removed (iframe path)

LegacyIframe(.test), legacy-url(.test) + the safeLegacyUrl validator, and the #673 framing-refusal workaround. contract.ts drops LegacyIframeResponse / renderer: "legacy-iframe" and adds HtmlFragmentResponse + RedirectResponse (+ ChangePostPayload, FragmentMessage).

Reproduction (examples/jobs)

/admin2/jobs/job/<pk>/change/?run_custom=1 renders the dual-listbox custom template in-shell. Verified end-to-end against the example backend: form-spec → html-fragment; POST empty → re-rendered html-fragment + error message; POST with selection → redirect mapped onto the SPA prefix + success message. New JobHtmlFragmentApiTests lock this in.

Verification

  • Frontend: pnpm test 247 passed (35 files), pnpm typecheck, pnpm lint:js, pnpm lint:css, pnpm build — all green. New HtmlFragment.test.tsx covers script execution, POST → validation re-inject → redirect navigate, and message toasts.
  • Backend: ruff check, ruff format --check, mypy (no issues), bandit (0 issues), pytest -q 75 passed. examples/jobs 8 passed.
  • Dep django-admin-rest-api ^1.6.0^1.7.0; poetry lock resolves 1.7.0. Version 1.11.21.12.0 + CHANGELOG [1.12.0]. README iframe section rewritten.

Closes #679

martin-castro-laminr-ai and others added 2 commits June 2, 2026 18:33
…re (#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>
…mplate, 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>
SPA to embed THIS legacy page in an iframe. The contract that must hold:
overridden change_view renders a non-standard template), renders it
server-side, strips the admin chrome, and hands the SPA the content-block
HTML — including the inline <script>/<style> below — to inject INSIDE its own
@MartinCastroAlvarez MartinCastroAlvarez merged commit 833bf4b into main Jun 2, 2026
6 checks passed
@MartinCastroAlvarez MartinCastroAlvarez deleted the feat/html-fragment-renderer-679 branch June 2, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom change_form_template: replace iframe fallback with server-rendered html-fragment (stay in the SPA)

3 participants