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
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.11.0] — 2026-06-02

### Added
- **Faithful rendering for every form-spec `widget.kind` (#664).** The
form-spec wire declares 23 `widget.kind` values; the change form previously
mapped only 5 and let the rest silently fall back to the control implied by
`FieldType` — so `hidden` rendered as a *visible, editable* input,
`split-datetime` collapsed to one control, and the multi-selects / `file`
had no faithful path. `adaptFormSpec` now maps **all 23** explicitly (an
exhaustive `Record<WidgetKind, …>` so a new kind is a compile error), and
`FieldInput` gained branches for `hidden` (real hidden input),
`split-datetime` (date + time), `select-date` (date input),
`checkbox-multiple` / `select-multiple` (checkbox bank / `<select multiple>`),
`autocomplete` / `autocomplete-multiple`, and `file` (limited control + a
legacy-admin note; upload itself still tracked by #241). Any future kind
with no rich renderer maps to an explicit, operator-visible
`unsupported_widget` tracked fallback — never a silent wrong control.
`adaptFormSpec.test.ts` now asserts every enum member maps to something
sensible.
- **System checks for misconfiguration (#667).** A new
`django_admin_react/checks.py` registers a `manage.py check` validator that
surfaces, with actionable hints: `django_admin_rest_api` missing from
`INSTALLED_APPS` (Error), an unimportable `ADMIN_SITE` dotted path (Error),
unknown `DJANGO_ADMIN_REACT` keys (Error, at startup instead of a lazy
`ValueError`), an `API_URL_PREFIX` that requires the consumer to mount the
REST API themselves (Warning), and a missing built SPA bundle / Vite
manifest (Warning).

### Changed
- **`list_display_links` is now honoured (#666).** The changelist wire emits
`list_display_links` (rest-api); the SPA links exactly the configured
column(s) to the change page — and links *none* (rows inert) when the admin
sets `list_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-api` floor to `^1.6.0` (#664).** 1.6.0 adds
`prepopulated_fields` + autocomplete hints to the form-spec wire (already
consumed for the add form via #245/#629; the autocomplete hint now drives
the `autocomplete` widget kind).
- **README parity table corrected (#668).** `raw_id_fields`, `radio_fields`,
and `filter_horizontal` / `filter_vertical` flip to ✅ (they ship today —
pk-input + lookup, radio bank, and the `ShuttleSelect` two-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
`—`), custom `AdminSite.each_context` extra keys, and `list_select_related`.

### Security
- **Validated + sandboxed the legacy-admin iframe (#665).** `legacy_url` from
the form-spec `legacy-iframe` fallback is now validated before it reaches
the `<iframe src>` / `<a href>` sinks: only a same-origin `http(s)` URL is
framed/linked; a `javascript:` / `data:` / `blob:` scheme or an off-origin
target renders an inert error card instead (mirroring the
`action-redirect.ts` discipline every other navigational sink in the SPA
follows). The iframe now carries
`sandbox="allow-forms allow-scripts allow-same-origin"` (defence in depth —
drops `allow-top-navigation` / `allow-popups` / `allow-modals`).
`SECURITY.md` §QSEC-03 gained `frame-src 'self'` and documents the
X-Frame-Options ↔ legacy-iframe interaction.

### Performance
- **Route-level code-splitting + show-all row windowing (#670).** `LoginPage`
and `CreatePage` are now `React.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.

### Fixed
- **i18n: routed untranslated strings through `t()` (#669).** `FieldInput`'s
`Lookup ↗` / lookup aria-label, the `— select —` / `(none)` placeholders,
and the time / array / range / FK placeholders, plus `App.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.

## [1.10.1] — 2026-06-02

### Fixed
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,8 +618,9 @@ all share the v1 wire contract. Per-feature live status below.
| `list_editable` + bulk PATCH | ✅ |
| `actions` — batch + detail (signature-classified) | ✅ |
| `autocomplete_fields` | ✅ |
| `raw_id_fields` (pk text input + lookup popup) | 🟡 [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) (API emits the hint; SPA still renders autocomplete) |
| `radio_fields` (inline radio buttons vs `<select>`) | 🟡 [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) (API emits the hint; SPA still renders dropdown) |
| `raw_id_fields` (pk text input + lookup popup) | ✅ |
| `radio_fields` (inline radio buttons vs `<select>`) | ✅ |
| `filter_horizontal` / `filter_vertical` (M2M shuttle) | ✅ |
| `ManyToManyField` read + write | ✅ |
| `inlines` (TabularInline / StackedInline) — read + write | ✅ |
| `FileField` / `ImageField` — read | ✅ |
Expand Down Expand Up @@ -648,14 +649,13 @@ issues link the work to close each gap.
|---|---|---|
| `change_form_template` / `add_form_template` overrides | **Embedded in an iframe** (since 1.9.0, #659): the change/add form-spec endpoint returns a `legacy-iframe` pointer and the SPA embeds the legacy admin page inside the SPA shell (breadcrumb / sidebar / toolbar stay SPA-rendered). Port the form to documented ModelAdmin hooks at your own pace. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `change_list_template` / `change_password_template` / `object_history_template` overrides | Silently ignored — those surfaces render entirely from the JSON wire. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `formfield_overrides = {Field: {"widget": CustomWidget}}` | Custom widget invisible — the SPA picks its own control from the field's `type`. No React-side widget-registration API yet. | [#625](https://github.com/MartinCastroAlvarez/django-admin-react/issues/625) |
| `raw_id_fields` | Falls back to the autocomplete picker (same as `autocomplete_fields`). Defeats the purpose for FKs with 10M+ rows where autocomplete `get_search_results` is too expensive. | [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) |
| `radio_fields = {"status": admin.HORIZONTAL}` | Renders a `<select>` (default choice control) instead of inline radio buttons. | [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) |
| `filter_horizontal` / `filter_vertical` (M2M shuttle widget) | Renders the generic multi-select checkbox list, not Django's two-pane shuttle. Switch the field to `autocomplete_fields` for a workable SPA UX. | [#627](https://github.com/MartinCastroAlvarez/django-admin-react/issues/627) |
| `formfield_overrides = {Field: {"widget": CustomWidget}}` | Custom widget rendered via the React widget-registration API (`registerFieldWidget`, #625) when the consumer registers a renderer for the widget class; otherwise falls back to the default control + an operator-visible "not registered" note. | [#625](https://github.com/MartinCastroAlvarez/django-admin-react/issues/625) |
| `empty_value_display` | **Hard-coded to `—`.** A per-`ModelAdmin` / per-field `empty_value_display` override is **not** surfaced — the SPA renders the literal em-dash for every empty value, regardless of the consumer's chosen placeholder. | [#629](https://github.com/MartinCastroAlvarez/django-admin-react/issues/629) |
| Custom `AdminSite.each_context(request)` extra keys | Not surfaced. Only a fixed set of site attributes (`site_header` / `site_title` / `site_logo` / `site_primary_color`) reaches the SPA; any extra keys a consumer adds in a custom `each_context` are dropped. | [#629](https://github.com/MartinCastroAlvarez/django-admin-react/issues/629) |
| `list_select_related` | A backend query-optimisation concern, applied server-side by the REST API's queryset; it changes query efficiency, **not** the wire shape, so it is intentionally invisible to the SPA (no client-visible effect to surface). | [#629](https://github.com/MartinCastroAlvarez/django-admin-react/issues/629) |
| `GenericForeignKey` / `GenericInlineModelAdmin` | Support gap — verify per-model before relying on the SPA. | [#628](https://github.com/MartinCastroAlvarez/django-admin-react/issues/628) |
| `LANGUAGE_CODE` / `gettext` / `Accept-Language` | The SPA chrome stays English; translated `verbose_name` / `help_text` / `@admin.action(description=_("..."))` are not surfaced per-request. | [#630](https://github.com/MartinCastroAlvarez/django-admin-react/issues/630) |
| `LANGUAGE_CODE` / `gettext` / `Accept-Language` | SPA chrome strings translate via the bundled catalogs (es / fr / pt; #630); translated `verbose_name` / `help_text` / `@admin.action(description=_("..."))` flow through when `LocaleMiddleware` is installed. | [#630](https://github.com/MartinCastroAlvarez/django-admin-react/issues/630) |
| `ModelAdmin.get_urls()` custom views | Opens as a popout (`<a target="_blank">`) into the Django-rendered HTML page — no SPA chrome, no breadcrumb. The link IS surfaced; the UX is just outside the SPA. | [#623](https://github.com/MartinCastroAlvarez/django-admin-react/issues/623) |
| Django 4.2 LTS support | Not yet — the package pins `django >= 5.0,<7.0`. | [#622](https://github.com/MartinCastroAlvarez/django-admin-react/issues/622) |

If your admin relies on any "silently ignored" hook above, the
typical workaround is to keep that model on the legacy
Expand Down
21 changes: 21 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ Content-Security-Policy:
connect-src 'self'; # the API is same-origin
manifest-src 'self';
worker-src 'self'; # the PWA service worker
frame-src 'self'; # legacy-admin iframe fallback (#659) — same-origin only
frame-ancestors 'none'; # clickjacking (with X_FRAME_OPTIONS)
base-uri 'self';
form-action 'self';
Expand All @@ -291,6 +292,26 @@ Caveats — **validate before enforcing**:
`style` attributes at runtime; it is far lower-risk than allowing
inline scripts. Drop it if you verify your build needs no inline
styles.
- `frame-src 'self'` and the **X-Frame-Options interaction** — the SPA's
legacy-admin fallback (#659) embeds the legacy admin change/add page in
a **same-origin** `<iframe>` when a `ModelAdmin` overrides
`change_form_template` / `add_form_template`. `frame-src 'self'` is the
explicit allowlist for that frame: it both **permits** the intended
same-origin embed and **blocks** an off-origin `legacy_url` (defence in
depth — the SPA also validates the URL client-side as same-origin
http(s) and sandboxes the iframe with `allow-forms allow-scripts
allow-same-origin`). Two interaction notes:
- **`X_FRAME_OPTIONS = "DENY"` will break the iframe** if it is applied
to the legacy admin's *own* responses, because the legacy admin page
is what gets framed. If you use the legacy-iframe fallback, scope
`X-Frame-Options` (and any `frame-ancestors`) so the legacy admin
permits same-origin framing — e.g. set `X_FRAME_OPTIONS = "SAMEORIGIN"`
for the legacy admin responses, or omit the header on that mount.
`frame-ancestors 'none'` on the *SPA shell* is fine — it controls who
may frame the SPA, not what the SPA may frame.
- If you do **not** use a custom `change_form_template` (the SPA renders
every form from the JSON form-spec), no iframe is ever created and you
can drop `frame-src` and keep `X_FRAME_OPTIONS = "DENY"` everywhere.
- This policy assumes the package is mounted on its own path under your
domain. If you already ship a project-wide CSP, **merge** these
directives rather than replacing your policy.
Expand Down
13 changes: 13 additions & 0 deletions django_admin_react/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from django.apps import AppConfig
from django.core.checks import register


class DjangoAdminReactConfig(AppConfig):
Expand All @@ -25,3 +26,15 @@ class DjangoAdminReactConfig(AppConfig):
label = "django_admin_react"
verbose_name = "Django Admin React"
default_auto_field = "django.db.models.BigAutoField"

def ready(self) -> None:
"""Register the package's system checks at app-load (#667).

Importing + registering here (not at module import time) keeps the
checks tied to the app registry being ready, matching Django's
documented pattern. The import is local so adding the app has no
eager import cost beyond the AppConfig itself.
"""
from django_admin_react.checks import check_django_admin_react

register(check_django_admin_react)
161 changes: 161 additions & 0 deletions django_admin_react/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""System checks for django_admin_react (#667).

Registered against ``django.core.checks`` so common misconfigurations
surface at ``manage.py check`` (and on every ``runserver`` boot) with an
actionable hint — instead of a lazy ``ValueError`` on first settings
access, a runtime 500, or a silent template fallback.

What we validate:

- ``django_admin_rest_api`` is installed (the package implements no API of
its own — every JSON endpoint lives in that sibling package).
- ``DJANGO_ADMIN_REACT["ADMIN_SITE"]`` (dotted path) actually imports.
- ``DJANGO_ADMIN_REACT`` has no unknown keys (the same guard ``conf._load``
enforces lazily — surfaced here at startup as a clean check error).
- ``API_URL_PREFIX`` mounting is coherent: when it is set, the package
skips its inline ``api/v1/`` include, so the consumer must mount
``django_admin_rest_api.urls`` themselves — a Warning reminds them.
- The built SPA bundle / Vite manifest exists (a Warning, not an Error —
the package serves a friendly "not built" shell, and the manifest is
absent in a source checkout / before ``pnpm build``).

Severities follow Django's convention: an ``Error`` blocks (the package
cannot work); a ``Warning`` is a likely-misconfiguration heads-up that
does not hard-block.
"""

from __future__ import annotations

from typing import Any

from django.core.checks import Error
from django.core.checks import Warning as CheckWarning

# Stable check IDs (Django convention ``<app_label>.E/W###``) so a consumer
# can silence a specific one via ``SILENCED_SYSTEM_CHECKS`` if they must.
ID_REST_API_MISSING = "django_admin_react.E001"
ID_ADMIN_SITE_IMPORT = "django_admin_react.E002"
ID_UNKNOWN_SETTINGS = "django_admin_react.E003"
ID_API_PREFIX_MOUNT = "django_admin_react.W001"
ID_BUNDLE_MISSING = "django_admin_react.W002"


def check_django_admin_react(app_configs: Any, **kwargs: Any) -> list[Any]:
"""Run every package configuration check.

Registered as a single callable (rather than many) so the import
surface stays small and the ordering is explicit. ``app_configs`` /
``kwargs`` are the Django check-framework signature; unused here
because the checks are global to the package, not per-app.
"""
errors: list[Any] = []
errors.extend(_check_rest_api_installed())
errors.extend(_check_admin_site_imports())
errors.extend(_check_settings_keys())
errors.extend(_check_api_prefix_coherence())
errors.extend(_check_bundle_built())
return errors


def _check_rest_api_installed() -> list[Any]:
from django.apps import apps as django_apps

if django_apps.is_installed("django_admin_rest_api"):
return []
return [
Error(
"'django_admin_rest_api' is not in INSTALLED_APPS.",
hint=(
"django-admin-react is a React SPA over django-admin-rest-api "
"and implements no API of its own. Add 'django_admin_rest_api' "
"to INSTALLED_APPS (it ships as a dependency)."
),
id=ID_REST_API_MISSING,
)
]


def _check_admin_site_imports() -> list[Any]:
from django.utils.module_loading import import_string

from django_admin_react import conf as dar_conf

dotted = dar_conf.ADMIN_SITE
try:
import_string(dotted)
except ImportError as exc:
return [
Error(
f"DJANGO_ADMIN_REACT['ADMIN_SITE'] = {dotted!r} could not be imported " f"({exc}).",
hint=(
"Point ADMIN_SITE at the dotted path of your AdminSite "
"instance (e.g. 'myproject.admin.site' or the default "
"'django.contrib.admin.site')."
),
id=ID_ADMIN_SITE_IMPORT,
)
]
return []


def _check_settings_keys() -> list[Any]:
from django.conf import settings as django_settings

from django_admin_react.conf import DEFAULTS

overrides = getattr(django_settings, "DJANGO_ADMIN_REACT", {}) or {}
unknown = sorted(set(overrides) - set(DEFAULTS))
if not unknown:
return []
return [
Error(
"Unknown DJANGO_ADMIN_REACT key(s): " + ", ".join(unknown) + ".",
hint=("Remove the typo'd key(s). Valid keys are: " + ", ".join(sorted(DEFAULTS)) + "."),
id=ID_UNKNOWN_SETTINGS,
)
]


def _check_api_prefix_coherence() -> list[Any]:
from django_admin_react import conf as dar_conf

prefix = dar_conf.API_URL_PREFIX
if prefix is None:
# Inline mount is active; the package mounts the API itself. Nothing
# the consumer must do.
return []
# The override is set, so `django_admin_react.urls` skips the inline
# `api/v1/` include — the consumer MUST mount the REST API themselves at
# that prefix or every SPA data call 404s.
return [
CheckWarning(
f"DJANGO_ADMIN_REACT['API_URL_PREFIX'] = {prefix!r} is set, so this "
"package does NOT mount the REST API inline.",
hint=(
"Mount the sibling API yourself at that prefix, e.g.\n"
f" path({prefix!r}, include('django_admin_rest_api.api.urls'))\n"
"or unset API_URL_PREFIX to use the package's inline 'api/v1/' mount."
),
id=ID_API_PREFIX_MOUNT,
)
]


def _check_bundle_built() -> list[Any]:
# Imported lazily so the check module has no import-time dependency on
# the view layer.
from django_admin_react.views import _MANIFEST_PATH

if _MANIFEST_PATH.is_file():
return []
return [
CheckWarning(
"The built SPA bundle / Vite manifest was not found at " f"{_MANIFEST_PATH}.",
hint=(
"Install the published wheel (which ships the built bundle), or "
"build the frontend from source with 'pnpm --dir frontend build'. "
"Until then the SPA shell renders a 'not built yet' page."
),
id=ID_BUNDLE_MISSING,
)
]
Loading
Loading