Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.10.0] — 2026-06-02

### Added
- **`examples/jobs` — custom-form / legacy-iframe fixture app.** A single
`Job` model whose `ModelAdmin` 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) — no SPA-specific API. Proves the two
rendering paths end-to-end: Path A (`/admin-react/jobs/job/<pk>/change/`)
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 inside its own chrome. Backend tests
cover both paths and the ordered POST contract (#659).

### Changed
- **Raised dependency floors for the cross-repo custom-form 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,<2.0.0` (matching MCP release, #70). The SPA's `LegacyIframe`
renderer (#659) already consumes the `legacy-iframe` discriminator.

## [1.9.0] — 2026-06-01

### Added
Expand Down
62 changes: 62 additions & 0 deletions examples/jobs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# examples/jobs — Custom-form / legacy-iframe demo app

A single `Job` model whose `ModelAdmin` deliberately exercises the
**request-driven custom-view + custom-template** pattern that real Django
admins use — and which the React SPA cannot render from the JSON form-spec.
The point of this fixture: it uses *only* documented `ModelAdmin` hooks
(`formfield_for_dbfield`, an admin `action`, `change_view`, a custom
template). No django-admin-react / -rest-api / -mcp-specific API appears
anywhere. If this app works on `/admin-react/`, any legacy `/admin/`
ModelAdmin works too — with at most a single-view iframe fallback.

## What's here

- `models.py` — `Job(name, metadata: JSONField, status)`.
- `admin.py` — `JobAdmin` + `get_step_registry()`.
- `templates/admin/jobs/job/run_custom.html` — the dual-listbox UI.
- `tests/test_admin.py` — backend tests for both render paths and the
ordered POST contract.

## The two rendering paths

**Path A — `/admin-react/jobs/job/<pk>/change/`** (no query)

The stock change form. `formfield_for_dbfield` swaps `metadata` to a large
textarea, so the form-spec endpoint returns `widget.kind == "textarea"` with
`vLargeTextField` in `widget.attrs.class`. The SPA renders this natively and
it matches the legacy `/admin/` change form field-for-field.

**Path B — `/admin-react/jobs/job/<pk>/change/?run_custom=1`**

`JobAdmin.change_view` branches on `request.GET` and `render()`s a hand-rolled
dual-listbox template — not a `ModelForm`, not fieldsets. There is no
form-spec to serialise, so the resolver returns:

```json
{ "renderer": "legacy-iframe",
"legacy_url": "/admin/legacy/jobs/job/<pk>/change/?run_custom=1" }
```

The SPA renders its breadcrumb / sidebar / toolbar from the spec, then embeds
the legacy URL in an iframe for the body. The custom POST contract
(`request.POST.getlist("selected_steps")`, ordered) is preserved.

## Bonus: action → redirect → variant

The **Run (Custom)** admin action returns an `HttpResponseRedirect` to
`?run_custom=1`. The SPA action runner follows the redirect (the
action-redirect contract, #620), after which Path B kicks in.

## How it's validated across the three packages

| Layer | What it asserts | Where |
|-------|-----------------|-------|
| rest-api | Path A → `form-spec` w/ textarea; Path B → `legacy-iframe` | `tests/test_form_spec.py` |
| mcp | `admin.form_spec` forwards the `legacy-iframe` discriminator | `tests/test_integration.py` |
| react | SPA renders `LegacyIframe` on `renderer == "legacy-iframe"` | `frontend` + this app |

## Run the backend tests

```bash
poetry run python examples/project/manage.py test examples.jobs
```
Empty file added examples/jobs/__init__.py
Empty file.
106 changes: 106 additions & 0 deletions examples/jobs/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""``JobAdmin`` — the custom-form fixture that proves the legacy-iframe escape.

This admin uses *only* documented Django ``ModelAdmin`` hooks — no
django-admin-react / -rest-api / -mcp-specific API. The point: if this runs
on the React admin at ``/admin-react/`` with at most a single-view iframe
fallback, then any legacy ``/admin/`` ModelAdmin does too.

Two rendering paths:

* **Path A** — ``/admin-react/jobs/job/<pk>/change/`` (no query). The stock
change form, fully describable by the form-spec contract.
``formfield_for_dbfield`` swaps ``metadata`` to a large textarea, which
the SPA renders natively from ``widget.kind == "textarea"``.
* **Path B** — ``?run_custom=1``. ``change_view`` returns a hand-rolled
dual-listbox template (not a ModelForm, not fieldsets). The form-spec
resolver detects the custom render and returns
``{renderer: "legacy-iframe", legacy_url: …}``; the SPA embeds the legacy
page in an iframe inside its own chrome.
"""

from __future__ import annotations

from django.contrib import admin
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.shortcuts import render
from django.urls import reverse

from examples.jobs.models import Job


def get_step_registry() -> list[dict]:
"""The catalogue of pipeline steps a Job can run (plain demo data)."""
return [
{"name": "fetch", "label": "Fetch inputs", "default_order": 1, "is_default": True},
{"name": "validate", "label": "Validate", "default_order": 2, "is_default": True},
{"name": "transform", "label": "Transform", "default_order": 3, "is_default": True},
{"name": "dry_run", "label": "Dry run", "default_order": 4, "is_default": False},
{"name": "notify", "label": "Notify stakeholders", "default_order": 5, "is_default": False},
{"name": "archive", "label": "Archive outputs", "default_order": 6, "is_default": False},
]


@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
list_display = ("name", "status")
actions = ["run_with_custom_steps"]

# 1) request-aware widget override on a single DB field (Path A).
def formfield_for_dbfield(self, db_field, request, **kwargs): # noqa: ANN001
if db_field.name == "metadata":
kwargs["widget"] = admin.widgets.AdminTextareaWidget(attrs={"class": "vLargeTextField"})
return super().formfield_for_dbfield(db_field, request, **kwargs)

# 2) action that redirects to the ?run_custom=1 variant of the change page.
@admin.action(description="Run (Custom)")
def run_with_custom_steps(self, request, queryset): # noqa: ANN001
if queryset.count() != 1:
self.message_user(request, "Pick exactly one row.", level=messages.ERROR)
return None
obj = queryset.get()
return HttpResponseRedirect(
reverse("admin:jobs_job_change", args=[obj.pk]) + "?run_custom=1"
)

# 3) change_view override branching on request.GET.
def change_view(self, request, object_id, form_url="", extra_context=None): # noqa: ANN001
if request.GET.get("run_custom") == "1":
return self.run_custom_view(request, object_id)
return super().change_view(request, object_id, form_url, extra_context)

# 4) custom view rendering a custom template — not a ModelForm / fieldsets.
def run_custom_view(self, request, object_id): # noqa: ANN001
obj = self.get_object(request, object_id)

if request.method == "POST":
selected = request.POST.getlist("selected_steps") # ordered list
if not selected:
messages.error(request, "Pick at least one step.")
return redirect(request.get_full_path())
messages.success(request, f"Queued {' → '.join(selected)}")
return redirect("admin:jobs_job_change", object_id)

all_steps = get_step_registry()
selected_steps = sorted(
(s for s in all_steps if s["is_default"]),
key=lambda s: s["default_order"],
)
available_steps = [s for s in all_steps if not s["is_default"]]

context = dict(
self.admin_site.each_context(request),
title=f"Configure Step Sequence: {obj.name}",
item_type="Job",
item_name=obj.name,
obj=obj,
available_steps=available_steps,
selected_steps=selected_steps,
all_steps=sorted(all_steps, key=lambda s: s["default_order"]),
back_url=reverse("admin:jobs_job_changelist"),
cancel_url=reverse("admin:jobs_job_change", args=[obj.pk]),
opts=self.model._meta,
preserved_filters=self.get_preserved_filters(request),
)
return render(request, "admin/jobs/job/run_custom.html", context)
8 changes: 8 additions & 0 deletions examples/jobs/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.apps import AppConfig


class JobsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "examples.jobs"
label = "jobs"
verbose_name = "Jobs"
27 changes: 27 additions & 0 deletions examples/jobs/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):
initial = True
dependencies: list = []

operations = [
migrations.CreateModel(
name="Job",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("metadata", models.JSONField(blank=True, default=dict)),
("status", models.CharField(default="idle", max_length=32)),
],
),
]
Empty file.
13 changes: 13 additions & 0 deletions examples/jobs/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.db import models


class Job(models.Model):
"""A background job whose admin mixes a stock change form with a custom,
request-driven step-sequencing view — see ``admin.JobAdmin``."""

name = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=32, default="idle")

def __str__(self) -> str:
return self.name
144 changes: 144 additions & 0 deletions examples/jobs/templates/admin/jobs/job/run_custom.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
{% extends "admin/base_site.html" %}
{% comment %}
Custom change-view template (Path B) — a dual-listbox / ordered step picker.

This is a *pure custom template* with a custom POST contract: no ModelForm,
no fieldsets, no model fields. The form-spec resolver detects this (the
overridden change_view renders a non-standard template) and tells the React
SPA to embed THIS legacy page in an iframe. The contract that must hold:

request.POST.getlist("selected_steps") ← ordered, in DOM order of the
right-hand column.
{% endcomment %}

{% block extrastyle %}{{ block.super }}
<style>
.dlb { display: flex; gap: 1.5rem; align-items: flex-start; margin: 1rem 0; }
.dlb__col { flex: 1; min-width: 200px; }
.dlb__col h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
.dlb__list { list-style: none; margin: 0; padding: 0.25rem; min-height: 220px;
border: 1px solid var(--border-color, #ccc); border-radius: 4px;
background: var(--body-bg, #fff); }
.dlb__item { display: flex; justify-content: space-between; align-items: center;
padding: 0.4rem 0.6rem; margin: 0.2rem; border: 1px solid var(--border-color, #ddd);
border-radius: 4px; cursor: grab; background: var(--darkened-bg, #f8f8f8); }
.dlb__item.is-selected { outline: 2px solid var(--primary, #79aec8); }
.dlb__item button { margin-left: 0.4rem; }
.dlb__ref { margin-top: 1rem; }
.dlb__ref th, .dlb__ref td { padding: 0.3rem 0.6rem; text-align: left; }
</style>
{% endblock %}

{% block content %}
<p class="help">Drag to reorder, or use the arrows. The order on the right is
the order the steps run in.</p>

<form method="post" action="{{ request.get_full_path }}" id="run-custom-form">
{% csrf_token %}

<div class="dlb">
<div class="dlb__col">
<h2>Available</h2>
<ul class="dlb__list" id="available" data-side="available">
{% for step in available_steps %}
<li class="dlb__item" draggable="true" data-step="{{ step.name }}">
<span>{{ step.label }}</span>
<button type="button" class="add-step" aria-label="Add {{ step.label }}">→</button>
</li>
{% endfor %}
</ul>
</div>

<div class="dlb__col">
<h2>Selected (ordered)</h2>
<ul class="dlb__list" id="selected" data-side="selected">
{% for step in selected_steps %}
<li class="dlb__item" draggable="true" data-step="{{ step.name }}">
<span>{{ step.label }}</span>
<span>
<button type="button" class="up-step" aria-label="Move up">↑</button>
<button type="button" class="down-step" aria-label="Move down">↓</button>
<button type="button" class="remove-step" aria-label="Remove">✕</button>
</span>
</li>
{% endfor %}
</ul>
</div>
</div>

{# Hidden inputs are (re)emitted in DOM order on submit so getlist() keeps order. #}
<div id="selected-inputs"></div>

<table class="dlb__ref">
<caption>All known steps</caption>
<thead><tr><th>Step</th><th>Default order</th></tr></thead>
<tbody>
{% for step in all_steps %}
<tr><td>{{ step.label }}</td><td>{{ step.default_order }}</td></tr>
{% endfor %}
</tbody>
</table>

<div class="submit-row">
<a href="{{ cancel_url }}" class="button cancel-link">Cancel</a>
<input type="submit" value="Queue steps" class="default">
</div>
</form>

<script>
(function () {
var form = document.getElementById("run-custom-form");
var available = document.getElementById("available");
var selected = document.getElementById("selected");
var inputs = document.getElementById("selected-inputs");

function move(item, target) { target.appendChild(item); }

// Click handlers (delegated).
document.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
var item = btn.closest(".dlb__item");
if (!item) return;
if (btn.classList.contains("add-step")) move(item, selected);
else if (btn.classList.contains("remove-step")) move(item, available);
else if (btn.classList.contains("up-step") && item.previousElementSibling)
item.parentNode.insertBefore(item, item.previousElementSibling);
else if (btn.classList.contains("down-step") && item.nextElementSibling)
item.parentNode.insertBefore(item.nextElementSibling, item);
});

// Minimal HTML5 drag-and-drop reordering / cross-column moves.
var dragged = null;
document.addEventListener("dragstart", function (e) {
var item = e.target.closest(".dlb__item");
if (item) { dragged = item; item.classList.add("is-selected"); }
});
document.addEventListener("dragend", function () {
if (dragged) dragged.classList.remove("is-selected");
dragged = null;
});
[available, selected].forEach(function (list) {
list.addEventListener("dragover", function (e) {
e.preventDefault();
if (!dragged) return;
var after = [].slice.call(list.querySelectorAll(".dlb__item:not(.is-selected)"))
.find(function (el) { return e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2; });
if (after) list.insertBefore(dragged, after); else list.appendChild(dragged);
});
});

// Emit hidden inputs in the right column's DOM order just before submit.
form.addEventListener("submit", function () {
inputs.innerHTML = "";
selected.querySelectorAll(".dlb__item").forEach(function (item) {
var input = document.createElement("input");
input.type = "hidden";
input.name = "selected_steps";
input.value = item.getAttribute("data-step");
inputs.appendChild(input);
});
});
})();
</script>
{% endblock %}
Empty file added examples/jobs/tests/__init__.py
Empty file.
Loading
Loading