All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- The detail page now opens in the read-only DETAILS view by default,
including on the Django-admin
/<app>/<model>/<pk>/change/URL alias (#682). Previously/change/forced edit mode, so a shared record link dropped recipients into an editable form (with empty inline "add" rows) — one stray keystroke from an accidental save. Now both/<pk>and/<pk>/change/render the same read-back view (FK/M2M as linked labels, choices as their display label, inlines as read-only tables); the toolbar Edit button flips the page into edit mode in place (no URL change), and?edit=1still deep-links straight to edit (and lands the "Save and continue editing" round-trip there). View-only users never see the Edit button. The add form (/add/) is unaffected — it still opens ready to fill in. No backend / form-spec contract change.
- Custom
change_form_templateadmins now render INSIDE the SPA shell via a server-rendered html-fragment — no iframe (#679). The form-spec endpoint (rest-api 1.7.0+, #75) renders a custom-template admin server-side, strips the admin chrome, and returns{renderer: "html-fragment", html, csrf_token, submit_url, method, messages}. The SPA injects that HTML into the content area while the breadcrumb / sidebar / title / toolbar stay React-rendered. Inline<script>/<style>the integrator's template emits execute / apply after injection:dangerouslySetInnerHTMLleaves parsed<script>elements inert, so the newHtmlFragmentcomponent clones each into a fresh<script>and re-inserts it (the dual-listbox JS only runs because of this). The injected<form>POSTs viafetch(…, {credentials: "include", headers: {"X-CSRFToken": …}})to the round-trip route; a returnedhtml-fragmentre-injects in place (validation errors, no SPA route change), a{renderer: "redirect", to}triggers an SPAnavigate(to)(never awindow.locationreload), and any Djangomessagessurface as toasts. The backend HTML is trusted — it is the integrator's own admin template, rendered behind the same auth as/admin/over the same-origin API — so the custom JS/CSS is injected verbatim, deliberately not sanitised (see theHtmlFragment.tsxtrust-boundary note). ModelAdmins that use only documented hooks (form/fieldsets/formfield_overrides/get_form) are unaffected — they keep rendering via the JSON field-map path. Theexamples/jobs?run_custom=1dual-listbox fixture exercises the full path (form-spec → POST → validation re-render → redirect) end-to-end against the example backend.
- The
legacy-iframerenderer and its iframe fallback are gone (#679). No iframe element is ever rendered for a custom-template form again. This dropsLegacyIframe(and the #673 "detect framing refusal → open-in-new-tab" workaround) and thesafeLegacyUrlsame-origin validation that only guarded the iframesrc. Custom-template admins now render in-shell via the html-fragment renderer above — noX-Frame-Options,SameSite=None; Secure, or cross-origin cookie bridge required. The contract type dropsLegacyIframeResponse/renderer: "legacy-iframe"and addsHtmlFragmentResponse(renderer: "html-fragment") +RedirectResponse(renderer: "redirect").
django-admin-rest-apifloor raised to^1.7.0(#679). 1.7.0 ships the html-fragment form-spec renderer (renderer: "html-fragment") plus the POST round-trip route the in-shell custom form submits to. The SPA still degrades gracefully to the JSON form-spec / detail-driven form on an older backend.
- Detail-page toolbar: History / Refresh / Edit / Delete now flow inline
with the custom
@admin.actionbuttons (#677). They were grouped in a right-alignedml-autocluster (#658/#672), which read as a second toolbar in its own column and could float disconnected from its row on narrow viewports. The toolbar is now a singleflex-wrapcontainer with noml-autospacer: every built-in is a plain button in the same flow, wrapping naturally wherever it falls, in DOM order[History] [...custom actions] [Refresh] [Edit] [Delete]. Destructive emphasis on Delete remains the button's own variant, not its position. Regression tests updated to pin the no-ml-autoinline contract.
- Detail-page toolbar no longer pushes the title off-screen (#672). The
header already stacked breadcrumb / title / toolbar as three full-width rows
(#658/#674), but a
ModelAdminwith 8+ actions still overflowed horizontally and dragged the H1 + breadcrumb out of view. Root cause was the flexboxmin-width: autodefault on the content column:<main>is a flex item, so it refused to shrink below the intrinsic width of the widest toolbar andflex-1blew it past the viewport — no header re-stacking could help.<main>now carriesmin-w-0so it shrinks to the viewport and the toolbar'sflex-wrapactually reflows the buttons; the toolbar row isw-full min-w-0, long action labels wrap inside their button (whitespace-normal break-words) instead of forming a wide min-content box, and the Edit/Delete cluster stays right-aligned (ml-auto) on the last line regardless of how many custom actions exist. Newexamples/many_actionsPipelineAdminfixture (12 batch + 2 detail-only actions) plusDetailPage.test.tsxguards pin the wrapping behaviour. - Legacy-iframe shows a clear fallback instead of a broken-image icon
(#673). When the legacy admin refuses to be framed (Django's
XFrameOptionsMiddlewaresendsX-Frame-Options: DENY, or a cross-originframe-ancestorsblock), the browser painted its broken-image glyph and noerrorevent fired.LegacyIframenow runs aloading → loaded → refusedstate machine —onLoadmarks the frame loaded; a ~4s timeout with noonLoadmarks itrefusedand swaps in an explicit "Embedding refused by the legacy admin — open in new tab" fallback (keeping the proven-working Open-in-new-tab button and the #665 same-origin validation +sandbox). README now documents the required backend headers (X-Frame-Options: SAMEORIGIN/ removingXFrameOptionsMiddleware; for cross-origin,Content-Security-Policy: frame-ancestors <spa-origin>plusSESSION_COOKIE_SAMESITE = "None"+SESSION_COOKIE_SECURE), and theexamples/jobs?run_custom=1variant exercises the path end-to-end.
- Faithful rendering for every form-spec
widget.kind(#664). The form-spec wire declares 23widget.kindvalues; the change form previously mapped only 5 and let the rest silently fall back to the control implied byFieldType— sohiddenrendered as a visible, editable input,split-datetimecollapsed to one control, and the multi-selects /filehad no faithful path.adaptFormSpecnow maps all 23 explicitly (an exhaustiveRecord<WidgetKind, …>so a new kind is a compile error), andFieldInputgained branches forhidden(real hidden input),split-datetime(date + time),select-date(date input),checkbox-multiple/select-multiple(checkbox bank /<select multiple>),autocomplete/autocomplete-multiple, andfile(limited control + a legacy-admin note; upload itself still tracked by #241). Any future kind with no rich renderer maps to an explicit, operator-visibleunsupported_widgettracked fallback — never a silent wrong control.adaptFormSpec.test.tsnow asserts every enum member maps to something sensible. - System checks for misconfiguration (#667). A new
django_admin_react/checks.pyregisters amanage.py checkvalidator that surfaces, with actionable hints:django_admin_rest_apimissing fromINSTALLED_APPS(Error), an unimportableADMIN_SITEdotted path (Error), unknownDJANGO_ADMIN_REACTkeys (Error, at startup instead of a lazyValueError), anAPI_URL_PREFIXthat requires the consumer to mount the REST API themselves (Warning), and a missing built SPA bundle / Vite manifest (Warning).
list_display_linksis now honoured (#666). The changelist wire emitslist_display_links(rest-api); the SPA links exactly the configured column(s) to the change page — and links none (rows inert) when the admin setslist_display_links = None. Previously the SPA hard-pinned the link to the first column. A pre-1.6.0 backend (no field on the wire) keeps the legacy first-column behaviour.- Raised the
django-admin-rest-apifloor to^1.6.0(#664). 1.6.0 addsprepopulated_fields+ autocomplete hints to the form-spec wire (already consumed for the add form via #245/#629; the autocomplete hint now drives theautocompletewidget kind). - README parity table corrected (#668).
raw_id_fields,radio_fields, andfilter_horizontal/filter_verticalflip to ✅ (they ship today — pk-input + lookup, radio bank, and theShuttleSelecttwo-pane widget). The stale "does NOT carry through" entries for those hooks were removed, and a new section documents the genuine gaps:empty_value_display(hard-coded—), customAdminSite.each_contextextra keys, andlist_select_related.
- Validated + sandboxed the legacy-admin iframe (#665).
legacy_urlfrom the form-speclegacy-iframefallback is now validated before it reaches the<iframe src>/<a href>sinks: only a same-originhttp(s)URL is framed/linked; ajavascript:/data:/blob:scheme or an off-origin target renders an inert error card instead (mirroring theaction-redirect.tsdiscipline every other navigational sink in the SPA follows). The iframe now carriessandbox="allow-forms allow-scripts allow-same-origin"(defence in depth — dropsallow-top-navigation/allow-popups/allow-modals).SECURITY.md§QSEC-03 gainedframe-src 'self'and documents the X-Frame-Options ↔ legacy-iframe interaction.
- Route-level code-splitting + show-all row windowing (#670).
LoginPageandCreatePageare nowReact.lazy-loaded at the route boundary (out of the first-paint main chunk). The "Show all N" (?all) list path applies native row windowing (content-visibility: auto) so off-screen rows skip layout/paint while staying in the DOM for find-in-page / a11y.
- i18n: routed untranslated strings through
t()(#669).FieldInput'sLookup ↗/ lookup aria-label, the— select —/(none)placeholders, and the time / array / range / FK placeholders, plusApp.tsx's "Page not found.", now go through the catalog; the new keys (and the #664 / #665 operator notes) were added to the es / fr / pt catalogs.
- Restored the detail-page stacked-header layout (#658), which the #657
module-split refactor silently reverted. The header had regressed to the
pre-#658 single-row layout where breadcrumb + title share a flex row with
the action toolbar — collapsing long single-token titles (filenames,
slugs, UUIDs) to one-word-per-line at full H1 size, and letting an 8+
action toolbar push the title off-screen. The header is again three
stacked full-width rows (breadcrumb / title with
overflow-wrap: anywhere/ toolbar with Edit·Delete pinned trailing-edge viaml-auto). Added aDetailPagetest asserting the stacked layout so a future refactor can't revert it unnoticed.
examples/jobs— custom-form / legacy-iframe fixture app. A singleJobmodel whoseModelAdminexercises the request-driven custom-view + custom-template pattern using only documented Django hooks (formfield_for_dbfield, an adminaction, achange_viewbranch, 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-textareametadatawidget; Path B (?run_custom=1) returnsrenderer: "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).
- Raised dependency floors for the cross-repo custom-form contract:
django-admin-rest-api→^1.5.0(broadenedlegacy-iframedetection for request-driven custom views, #59) anddjango-admin-mcp-api→>=1.3.0,<2.0.0(matching MCP release, #70). The SPA'sLegacyIframerenderer (#659) already consumes thelegacy-iframediscriminator.
- Change-form parity via the rest-api form-spec endpoint (#659). The
change form is now driven by
GET <app>/<model>/<pk>/form-spec/(django-admin-rest-api 1.4.0+, #59) instead of discovering fields client-side from the model serializer. The SPA now honours the ModelAdmin layer: request-awareget_form(request, obj)/get_fieldsets(request, obj)/get_readonly_fields(request, obj),formfield_overrides, customFormclasses, and the admin relation widgets — resolved server-side and mapped through the closedwidget.kindenum. The original change-form querystring is forwarded, so aget_formthat swaps theFormon?variant=…renders the same fields the legacy/admin/does. When the backend can't render the form from JSON (achange_form_templateoverride →renderer: "legacy-iframe"), the SPA embeds the legacy admin page in an iframe inside the SPA shell instead of silently dropping the customisation (closes part of #624). A spec-fetch failure (older backend) degrades gracefully to the previous detail-payload-driven form. New API client methodformSpec()+useFormSpechook; the existingFieldInputrenders the adapted fields unchanged (one control set, no drift).
- Split DetailPage/ListPage into focused modules (no behavior change)
(#657). Extracted the inner components of
DetailPage.tsx(DetailValue,FieldsetSection,ObjectActionButton,EditForm,CustomViewsMenu,DeleteButton,CollapsedEmptyInline,InlineSection) into one-per-file modules underapps/web/src/pages/detail/, and liftedListPage.tsx'scapitalize/emptyLabelhelpers intoapps/web/src/pages/list/helpers.ts. The page components keep their original paths + named exports; runtime behavior, props, and rendered output are unchanged. - Python lint stack consolidated onto Ruff (#651, #652). Removed Black,
standalone isort, and flake8 entirely (their
[tool.*]config, dev dependencies, pre-commit hooks, andscripts/lint.shsteps). Ruff now owns lint + format + import order (theIrules), with mypy + bandit alongside — resolving the three-formatter conflict (#452/#452-skew) and the Black 24-vs-26 pin skew. The now-green Python lint gate (ruff check + ruff format --check + mypy + bandit) is wired into backend CI. - mypy tightened on the package (#655). Enabled the
disallow_untyped_defsandwarn_return_anystrict subset fordjango_admin_react; typed theadmin_siteview helpers asAdminSite(type-only import) instead ofAny. - Frontend
@typescript-eslint/no-explicit-anypromoted fromofftoerror(#656) to lock in the existing zero-anystate, and added/**JSDoc to theCheckbox,Input,Spinner,EmptyState, andDateHierarchyBarprimitives.
- Dead
django_admin_react/audit.pymodule (#654). It was imported nowhere and had 0% coverage; theLogEntryaccess it duplicated belongs in the siblingdjango-admin-rest-api.
- Dangling documentation references (#653). Repointed or removed docstring /
comment / pre-commit citations to docs that no longer exist (
docs/ux/pwa.md,pwa.md,theming.md,ACCEPTANCE.md,REVIEW_CHECKLIST.md,docs/threat-model.md) so they target the survivingARCHITECTURE.md/SECURITY.mdsections. Added a fast doc-reference guard (tests/test_doc_refs.py+ a pre-commit hook) that fails when a*.mdfile or§Nsection cited in source no longer exists. - Stale comments (#654). Removed the misleading "Real implementation lands
in PR #2" note on the shipped
_PackageSettingsdataclass and a dead# noqa: ARG002line inviews.pythat suppressed nothing.