Skip to content

Commit 3aeaacd

Browse files
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>
1 parent 37508c2 commit 3aeaacd

3 files changed

Lines changed: 95 additions & 11 deletions

File tree

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+
)

0 commit comments

Comments
 (0)