Skip to content

Commit 3aab3e0

Browse files
feat(examples): jobs custom-form / legacy-iframe fixture + dep bumps + 1.10.0
Adds examples/jobs — a Job ModelAdmin that exercises the request-driven custom-view + custom-template pattern using only documented Django hooks (formfield_for_dbfield, an admin action, a change_view branch, a hand-rolled dual-listbox template). Path A renders the stock form-spec with the large-textarea metadata widget; Path B (?run_custom=1) returns renderer: legacy-iframe and the SPA embeds the legacy page in an iframe. Backend tests cover both paths and the ordered POST contract (#659). Raises dependency floors for the cross-repo contract: django-admin-rest-api ^1.5.0 (broadened legacy-iframe detection for request-driven custom views, #59) and django-admin-mcp-api >=1.3.0 (matching MCP release, #70). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 76277ab commit 3aab3e0

14 files changed

Lines changed: 466 additions & 11 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [1.10.0] — 2026-06-02
11+
12+
### Added
13+
- **`examples/jobs` — custom-form / legacy-iframe fixture app.** A single
14+
`Job` model whose `ModelAdmin` exercises the request-driven custom-view +
15+
custom-template pattern using *only* documented Django hooks
16+
(`formfield_for_dbfield`, an admin `action`, a `change_view` branch, a
17+
hand-rolled dual-listbox template) — no SPA-specific API. Proves the two
18+
rendering paths end-to-end: Path A (`/admin-react/jobs/job/<pk>/change/`)
19+
renders the stock form-spec with the large-textarea `metadata` widget;
20+
Path B (`?run_custom=1`) returns `renderer: "legacy-iframe"` and the SPA
21+
embeds the legacy page in an iframe inside its own chrome. Backend tests
22+
cover both paths and the ordered POST contract (#659).
23+
24+
### Changed
25+
- **Raised dependency floors for the cross-repo custom-form contract:**
26+
`django-admin-rest-api``^1.5.0` (broadened `legacy-iframe` detection
27+
for request-driven custom views, #59) and `django-admin-mcp-api`
28+
`>=1.3.0,<2.0.0` (matching MCP release, #70). The SPA's `LegacyIframe`
29+
renderer (#659) already consumes the `legacy-iframe` discriminator.
30+
1031
## [1.9.0] — 2026-06-01
1132

1233
### Added

examples/jobs/README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# examples/jobs — Custom-form / legacy-iframe demo app
2+
3+
A single `Job` model whose `ModelAdmin` deliberately exercises the
4+
**request-driven custom-view + custom-template** pattern that real Django
5+
admins use — and which the React SPA cannot render from the JSON form-spec.
6+
The point of this fixture: it uses *only* documented `ModelAdmin` hooks
7+
(`formfield_for_dbfield`, an admin `action`, `change_view`, a custom
8+
template). No django-admin-react / -rest-api / -mcp-specific API appears
9+
anywhere. If this app works on `/admin-react/`, any legacy `/admin/`
10+
ModelAdmin works too — with at most a single-view iframe fallback.
11+
12+
## What's here
13+
14+
- `models.py``Job(name, metadata: JSONField, status)`.
15+
- `admin.py``JobAdmin` + `get_step_registry()`.
16+
- `templates/admin/jobs/job/run_custom.html` — the dual-listbox UI.
17+
- `tests/test_admin.py` — backend tests for both render paths and the
18+
ordered POST contract.
19+
20+
## The two rendering paths
21+
22+
**Path A — `/admin-react/jobs/job/<pk>/change/`** (no query)
23+
24+
The stock change form. `formfield_for_dbfield` swaps `metadata` to a large
25+
textarea, so the form-spec endpoint returns `widget.kind == "textarea"` with
26+
`vLargeTextField` in `widget.attrs.class`. The SPA renders this natively and
27+
it matches the legacy `/admin/` change form field-for-field.
28+
29+
**Path B — `/admin-react/jobs/job/<pk>/change/?run_custom=1`**
30+
31+
`JobAdmin.change_view` branches on `request.GET` and `render()`s a hand-rolled
32+
dual-listbox template — not a `ModelForm`, not fieldsets. There is no
33+
form-spec to serialise, so the resolver returns:
34+
35+
```json
36+
{ "renderer": "legacy-iframe",
37+
"legacy_url": "/admin/legacy/jobs/job/<pk>/change/?run_custom=1" }
38+
```
39+
40+
The SPA renders its breadcrumb / sidebar / toolbar from the spec, then embeds
41+
the legacy URL in an iframe for the body. The custom POST contract
42+
(`request.POST.getlist("selected_steps")`, ordered) is preserved.
43+
44+
## Bonus: action → redirect → variant
45+
46+
The **Run (Custom)** admin action returns an `HttpResponseRedirect` to
47+
`?run_custom=1`. The SPA action runner follows the redirect (the
48+
action-redirect contract, #620), after which Path B kicks in.
49+
50+
## How it's validated across the three packages
51+
52+
| Layer | What it asserts | Where |
53+
|-------|-----------------|-------|
54+
| rest-api | Path A → `form-spec` w/ textarea; Path B → `legacy-iframe` | `tests/test_form_spec.py` |
55+
| mcp | `admin.form_spec` forwards the `legacy-iframe` discriminator | `tests/test_integration.py` |
56+
| react | SPA renders `LegacyIframe` on `renderer == "legacy-iframe"` | `frontend` + this app |
57+
58+
## Run the backend tests
59+
60+
```bash
61+
poetry run python examples/project/manage.py test examples.jobs
62+
```

examples/jobs/__init__.py

Whitespace-only changes.

examples/jobs/admin.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""``JobAdmin`` — the custom-form fixture that proves the legacy-iframe escape.
2+
3+
This admin uses *only* documented Django ``ModelAdmin`` hooks — no
4+
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.
7+
8+
Two rendering paths:
9+
10+
* **Path A** — ``/admin-react/jobs/job/<pk>/change/`` (no query). The stock
11+
change form, fully describable by the form-spec contract.
12+
``formfield_for_dbfield`` swaps ``metadata`` to a large textarea, which
13+
the SPA renders natively from ``widget.kind == "textarea"``.
14+
* **Path B** — ``?run_custom=1``. ``change_view`` returns a hand-rolled
15+
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.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from django.contrib import admin
24+
from django.contrib import messages
25+
from django.http import HttpResponseRedirect
26+
from django.shortcuts import redirect
27+
from django.shortcuts import render
28+
from django.urls import reverse
29+
30+
from examples.jobs.models import Job
31+
32+
33+
def get_step_registry() -> list[dict]:
34+
"""The catalogue of pipeline steps a Job can run (plain demo data)."""
35+
return [
36+
{"name": "fetch", "label": "Fetch inputs", "default_order": 1, "is_default": True},
37+
{"name": "validate", "label": "Validate", "default_order": 2, "is_default": True},
38+
{"name": "transform", "label": "Transform", "default_order": 3, "is_default": True},
39+
{"name": "dry_run", "label": "Dry run", "default_order": 4, "is_default": False},
40+
{"name": "notify", "label": "Notify stakeholders", "default_order": 5, "is_default": False},
41+
{"name": "archive", "label": "Archive outputs", "default_order": 6, "is_default": False},
42+
]
43+
44+
45+
@admin.register(Job)
46+
class JobAdmin(admin.ModelAdmin):
47+
list_display = ("name", "status")
48+
actions = ["run_with_custom_steps"]
49+
50+
# 1) request-aware widget override on a single DB field (Path A).
51+
def formfield_for_dbfield(self, db_field, request, **kwargs): # noqa: ANN001
52+
if db_field.name == "metadata":
53+
kwargs["widget"] = admin.widgets.AdminTextareaWidget(attrs={"class": "vLargeTextField"})
54+
return super().formfield_for_dbfield(db_field, request, **kwargs)
55+
56+
# 2) action that redirects to the ?run_custom=1 variant of the change page.
57+
@admin.action(description="Run (Custom)")
58+
def run_with_custom_steps(self, request, queryset): # noqa: ANN001
59+
if queryset.count() != 1:
60+
self.message_user(request, "Pick exactly one row.", level=messages.ERROR)
61+
return None
62+
obj = queryset.get()
63+
return HttpResponseRedirect(
64+
reverse("admin:jobs_job_change", args=[obj.pk]) + "?run_custom=1"
65+
)
66+
67+
# 3) change_view override branching on request.GET.
68+
def change_view(self, request, object_id, form_url="", extra_context=None): # noqa: ANN001
69+
if request.GET.get("run_custom") == "1":
70+
return self.run_custom_view(request, object_id)
71+
return super().change_view(request, object_id, form_url, extra_context)
72+
73+
# 4) custom view rendering a custom template — not a ModelForm / fieldsets.
74+
def run_custom_view(self, request, object_id): # noqa: ANN001
75+
obj = self.get_object(request, object_id)
76+
77+
if request.method == "POST":
78+
selected = request.POST.getlist("selected_steps") # ordered list
79+
if not selected:
80+
messages.error(request, "Pick at least one step.")
81+
return redirect(request.get_full_path())
82+
messages.success(request, f"Queued {' → '.join(selected)}")
83+
return redirect("admin:jobs_job_change", object_id)
84+
85+
all_steps = get_step_registry()
86+
selected_steps = sorted(
87+
(s for s in all_steps if s["is_default"]),
88+
key=lambda s: s["default_order"],
89+
)
90+
available_steps = [s for s in all_steps if not s["is_default"]]
91+
92+
context = dict(
93+
self.admin_site.each_context(request),
94+
title=f"Configure Step Sequence: {obj.name}",
95+
item_type="Job",
96+
item_name=obj.name,
97+
obj=obj,
98+
available_steps=available_steps,
99+
selected_steps=selected_steps,
100+
all_steps=sorted(all_steps, key=lambda s: s["default_order"]),
101+
back_url=reverse("admin:jobs_job_changelist"),
102+
cancel_url=reverse("admin:jobs_job_change", args=[obj.pk]),
103+
opts=self.model._meta,
104+
preserved_filters=self.get_preserved_filters(request),
105+
)
106+
return render(request, "admin/jobs/job/run_custom.html", context)

examples/jobs/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
3+
4+
class JobsConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "examples.jobs"
7+
label = "jobs"
8+
verbose_name = "Jobs"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from django.db import migrations
2+
from django.db import models
3+
4+
5+
class Migration(migrations.Migration):
6+
initial = True
7+
dependencies: list = []
8+
9+
operations = [
10+
migrations.CreateModel(
11+
name="Job",
12+
fields=[
13+
(
14+
"id",
15+
models.BigAutoField(
16+
auto_created=True,
17+
primary_key=True,
18+
serialize=False,
19+
verbose_name="ID",
20+
),
21+
),
22+
("name", models.CharField(max_length=255)),
23+
("metadata", models.JSONField(blank=True, default=dict)),
24+
("status", models.CharField(default="idle", max_length=32)),
25+
],
26+
),
27+
]

examples/jobs/migrations/__init__.py

Whitespace-only changes.

examples/jobs/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.db import models
2+
3+
4+
class Job(models.Model):
5+
"""A background job whose admin mixes a stock change form with a custom,
6+
request-driven step-sequencing view — see ``admin.JobAdmin``."""
7+
8+
name = models.CharField(max_length=255)
9+
metadata = models.JSONField(default=dict, blank=True)
10+
status = models.CharField(max_length=32, default="idle")
11+
12+
def __str__(self) -> str:
13+
return self.name
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
{% extends "admin/base_site.html" %}
2+
{% comment %}
3+
Custom change-view template (Path B) — a dual-listbox / ordered step picker.
4+
5+
This is a *pure custom template* with a custom POST contract: no ModelForm,
6+
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:
9+
10+
request.POST.getlist("selected_steps") ← ordered, in DOM order of the
11+
right-hand column.
12+
{% endcomment %}
13+
14+
{% block extrastyle %}{{ block.super }}
15+
<style>
16+
.dlb { display: flex; gap: 1.5rem; align-items: flex-start; margin: 1rem 0; }
17+
.dlb__col { flex: 1; min-width: 200px; }
18+
.dlb__col h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
19+
.dlb__list { list-style: none; margin: 0; padding: 0.25rem; min-height: 220px;
20+
border: 1px solid var(--border-color, #ccc); border-radius: 4px;
21+
background: var(--body-bg, #fff); }
22+
.dlb__item { display: flex; justify-content: space-between; align-items: center;
23+
padding: 0.4rem 0.6rem; margin: 0.2rem; border: 1px solid var(--border-color, #ddd);
24+
border-radius: 4px; cursor: grab; background: var(--darkened-bg, #f8f8f8); }
25+
.dlb__item.is-selected { outline: 2px solid var(--primary, #79aec8); }
26+
.dlb__item button { margin-left: 0.4rem; }
27+
.dlb__ref { margin-top: 1rem; }
28+
.dlb__ref th, .dlb__ref td { padding: 0.3rem 0.6rem; text-align: left; }
29+
</style>
30+
{% endblock %}
31+
32+
{% block content %}
33+
<p class="help">Drag to reorder, or use the arrows. The order on the right is
34+
the order the steps run in.</p>
35+
36+
<form method="post" action="{{ request.get_full_path }}" id="run-custom-form">
37+
{% csrf_token %}
38+
39+
<div class="dlb">
40+
<div class="dlb__col">
41+
<h2>Available</h2>
42+
<ul class="dlb__list" id="available" data-side="available">
43+
{% for step in available_steps %}
44+
<li class="dlb__item" draggable="true" data-step="{{ step.name }}">
45+
<span>{{ step.label }}</span>
46+
<button type="button" class="add-step" aria-label="Add {{ step.label }}"></button>
47+
</li>
48+
{% endfor %}
49+
</ul>
50+
</div>
51+
52+
<div class="dlb__col">
53+
<h2>Selected (ordered)</h2>
54+
<ul class="dlb__list" id="selected" data-side="selected">
55+
{% for step in selected_steps %}
56+
<li class="dlb__item" draggable="true" data-step="{{ step.name }}">
57+
<span>{{ step.label }}</span>
58+
<span>
59+
<button type="button" class="up-step" aria-label="Move up"></button>
60+
<button type="button" class="down-step" aria-label="Move down"></button>
61+
<button type="button" class="remove-step" aria-label="Remove"></button>
62+
</span>
63+
</li>
64+
{% endfor %}
65+
</ul>
66+
</div>
67+
</div>
68+
69+
{# Hidden inputs are (re)emitted in DOM order on submit so getlist() keeps order. #}
70+
<div id="selected-inputs"></div>
71+
72+
<table class="dlb__ref">
73+
<caption>All known steps</caption>
74+
<thead><tr><th>Step</th><th>Default order</th></tr></thead>
75+
<tbody>
76+
{% for step in all_steps %}
77+
<tr><td>{{ step.label }}</td><td>{{ step.default_order }}</td></tr>
78+
{% endfor %}
79+
</tbody>
80+
</table>
81+
82+
<div class="submit-row">
83+
<a href="{{ cancel_url }}" class="button cancel-link">Cancel</a>
84+
<input type="submit" value="Queue steps" class="default">
85+
</div>
86+
</form>
87+
88+
<script>
89+
(function () {
90+
var form = document.getElementById("run-custom-form");
91+
var available = document.getElementById("available");
92+
var selected = document.getElementById("selected");
93+
var inputs = document.getElementById("selected-inputs");
94+
95+
function move(item, target) { target.appendChild(item); }
96+
97+
// Click handlers (delegated).
98+
document.addEventListener("click", function (e) {
99+
var btn = e.target.closest("button");
100+
if (!btn) return;
101+
var item = btn.closest(".dlb__item");
102+
if (!item) return;
103+
if (btn.classList.contains("add-step")) move(item, selected);
104+
else if (btn.classList.contains("remove-step")) move(item, available);
105+
else if (btn.classList.contains("up-step") && item.previousElementSibling)
106+
item.parentNode.insertBefore(item, item.previousElementSibling);
107+
else if (btn.classList.contains("down-step") && item.nextElementSibling)
108+
item.parentNode.insertBefore(item.nextElementSibling, item);
109+
});
110+
111+
// Minimal HTML5 drag-and-drop reordering / cross-column moves.
112+
var dragged = null;
113+
document.addEventListener("dragstart", function (e) {
114+
var item = e.target.closest(".dlb__item");
115+
if (item) { dragged = item; item.classList.add("is-selected"); }
116+
});
117+
document.addEventListener("dragend", function () {
118+
if (dragged) dragged.classList.remove("is-selected");
119+
dragged = null;
120+
});
121+
[available, selected].forEach(function (list) {
122+
list.addEventListener("dragover", function (e) {
123+
e.preventDefault();
124+
if (!dragged) return;
125+
var after = [].slice.call(list.querySelectorAll(".dlb__item:not(.is-selected)"))
126+
.find(function (el) { return e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2; });
127+
if (after) list.insertBefore(dragged, after); else list.appendChild(dragged);
128+
});
129+
});
130+
131+
// Emit hidden inputs in the right column's DOM order just before submit.
132+
form.addEventListener("submit", function () {
133+
inputs.innerHTML = "";
134+
selected.querySelectorAll(".dlb__item").forEach(function (item) {
135+
var input = document.createElement("input");
136+
input.type = "hidden";
137+
input.name = "selected_steps";
138+
input.value = item.getAttribute("data-step");
139+
inputs.appendChild(input);
140+
});
141+
});
142+
})();
143+
</script>
144+
{% endblock %}

examples/jobs/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)