Skip to content

Commit daea792

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): audit fixes (widget kinds, iframe sandbox, checks, parity, i18n, perf) + dep ^1.6.0 + 1.11.0 (#675)
* chore: bump django-admin-rest-api to ^1.6.0 + version 1.11.0 + changelog Raise the REST API floor to 1.6.0 (form-spec now emits prepopulated_fields + autocomplete hints consumed by the widget-kind rendering), bump the package to 1.11.0, and add the [1.11.0] CHANGELOG section covering #664#670. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(spa): map every form-spec widget.kind + route strings through i18n (#664, #669) #664: adaptFormSpec now maps all 23 WidgetKind values onto an exhaustive Record<WidgetKind, WidgetHint|undefined> (a new kind is a compile error). FieldInput renders hidden as a real hidden input, split-datetime as date+time, select-date as a date input, checkbox-multiple/select-multiple as a checkbox bank / <select multiple>, autocomplete(-multiple), and file (limited control + legacy-admin note, upload tracked by #241). Kinds with no faithful control map to an explicit operator-visible unsupported_widget tracked fallback — never a silent wrong control. The test asserts every enum member maps sensibly. #669: FieldInput's Lookup ↗ / lookup aria-label, — select — / (none), and the time/array/range/FK placeholders now go through t(); new keys added to the es/fr/pt catalogs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): validate + sandbox the legacy-admin iframe (#665) legacy_url from the form-spec legacy-iframe fallback is now validated before reaching the <iframe src> / <a href> sinks: only a same-origin http(s) URL is framed/linked (new safeLegacyUrl helper, mirroring action-redirect.ts); a javascript:/data:/blob: scheme or off-origin target renders an inert error card. The iframe carries sandbox="allow-forms allow-scripts allow-same-origin". SECURITY.md §QSEC-03 adds frame-src 'self' and documents the X-Frame-Options ↔ legacy-iframe interaction. Tests cover the validator and the rejection paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(spa): honour list_display_links in the changelist (#666) The changelist wire emits list_display_links; the Table/RecordCardList primitives now link exactly the columns flagged isLink (set by ListPage from the wire) instead of hard-pinning the first column — and link none / make the row inert when the admin sets list_display_links = None. A pre-1.6.0 backend (field absent) keeps the legacy first-column behaviour. Table tests cover the explicit-link, no-link, and legacy-fallback paths. Note: ListPage also gains virtualizeRows on the Table for #670 (committed next). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add django.core.checks for misconfiguration (#667) New django_admin_react/checks.py registers a manage.py check validator with actionable hints: django_admin_rest_api missing from INSTALLED_APPS (Error), unimportable ADMIN_SITE dotted path (Error), unknown DJANGO_ADMIN_REACT keys (Error, at startup vs a lazy ValueError), API_URL_PREFIX requiring the consumer to mount the API (Warning), and a missing built bundle/Vite manifest (Warning). Registered in AppConfig.ready(). Adds django_admin_rest_api to the test project's INSTALLED_APPS (per its design) and a full-coverage test_checks.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(spa): lazy-load login/create routes + show-all windowing; README parity fixes (#670, #668) #670: LoginPage and CreatePage are now React.lazy-loaded at the route boundary (out of the first-paint main chunk), wrapped in Suspense; App.tsx's "Page not found." now goes through t(). The "Show all N" (?all) list path uses native content-visibility row windowing (Table.virtualizeRows, wired in ListPage). #668: README parity table corrected — raw_id_fields / radio_fields / filter_horizontal flip to ✅ (they ship today), stale "does NOT carry through" entries removed, and a new section documents empty_value_display (hard-coded —), custom each_context, and list_select_related. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 478e0d4 commit daea792

24 files changed

Lines changed: 1169 additions & 50 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [1.11.0] — 2026-06-02
11+
12+
### Added
13+
- **Faithful rendering for every form-spec `widget.kind` (#664).** The
14+
form-spec wire declares 23 `widget.kind` values; the change form previously
15+
mapped only 5 and let the rest silently fall back to the control implied by
16+
`FieldType` — so `hidden` rendered as a *visible, editable* input,
17+
`split-datetime` collapsed to one control, and the multi-selects / `file`
18+
had no faithful path. `adaptFormSpec` now maps **all 23** explicitly (an
19+
exhaustive `Record<WidgetKind, …>` so a new kind is a compile error), and
20+
`FieldInput` gained branches for `hidden` (real hidden input),
21+
`split-datetime` (date + time), `select-date` (date input),
22+
`checkbox-multiple` / `select-multiple` (checkbox bank / `<select multiple>`),
23+
`autocomplete` / `autocomplete-multiple`, and `file` (limited control + a
24+
legacy-admin note; upload itself still tracked by #241). Any future kind
25+
with no rich renderer maps to an explicit, operator-visible
26+
`unsupported_widget` tracked fallback — never a silent wrong control.
27+
`adaptFormSpec.test.ts` now asserts every enum member maps to something
28+
sensible.
29+
- **System checks for misconfiguration (#667).** A new
30+
`django_admin_react/checks.py` registers a `manage.py check` validator that
31+
surfaces, with actionable hints: `django_admin_rest_api` missing from
32+
`INSTALLED_APPS` (Error), an unimportable `ADMIN_SITE` dotted path (Error),
33+
unknown `DJANGO_ADMIN_REACT` keys (Error, at startup instead of a lazy
34+
`ValueError`), an `API_URL_PREFIX` that requires the consumer to mount the
35+
REST API themselves (Warning), and a missing built SPA bundle / Vite
36+
manifest (Warning).
37+
38+
### Changed
39+
- **`list_display_links` is now honoured (#666).** The changelist wire emits
40+
`list_display_links` (rest-api); the SPA links exactly the configured
41+
column(s) to the change page — and links *none* (rows inert) when the admin
42+
sets `list_display_links = None`. Previously the SPA hard-pinned the link to
43+
the first column. A pre-1.6.0 backend (no field on the wire) keeps the
44+
legacy first-column behaviour.
45+
- **Raised the `django-admin-rest-api` floor to `^1.6.0` (#664).** 1.6.0 adds
46+
`prepopulated_fields` + autocomplete hints to the form-spec wire (already
47+
consumed for the add form via #245/#629; the autocomplete hint now drives
48+
the `autocomplete` widget kind).
49+
- **README parity table corrected (#668).** `raw_id_fields`, `radio_fields`,
50+
and `filter_horizontal` / `filter_vertical` flip to ✅ (they ship today —
51+
pk-input + lookup, radio bank, and the `ShuttleSelect` two-pane widget).
52+
The stale "does NOT carry through" entries for those hooks were removed, and
53+
a new section documents the genuine gaps: `empty_value_display` (hard-coded
54+
``), custom `AdminSite.each_context` extra keys, and `list_select_related`.
55+
56+
### Security
57+
- **Validated + sandboxed the legacy-admin iframe (#665).** `legacy_url` from
58+
the form-spec `legacy-iframe` fallback is now validated before it reaches
59+
the `<iframe src>` / `<a href>` sinks: only a same-origin `http(s)` URL is
60+
framed/linked; a `javascript:` / `data:` / `blob:` scheme or an off-origin
61+
target renders an inert error card instead (mirroring the
62+
`action-redirect.ts` discipline every other navigational sink in the SPA
63+
follows). The iframe now carries
64+
`sandbox="allow-forms allow-scripts allow-same-origin"` (defence in depth —
65+
drops `allow-top-navigation` / `allow-popups` / `allow-modals`).
66+
`SECURITY.md` §QSEC-03 gained `frame-src 'self'` and documents the
67+
X-Frame-Options ↔ legacy-iframe interaction.
68+
69+
### Performance
70+
- **Route-level code-splitting + show-all row windowing (#670).** `LoginPage`
71+
and `CreatePage` are now `React.lazy`-loaded at the route boundary (out of
72+
the first-paint main chunk). The "Show all N" (`?all`) list path applies
73+
native row windowing (`content-visibility: auto`) so off-screen rows skip
74+
layout/paint while staying in the DOM for find-in-page / a11y.
75+
76+
### Fixed
77+
- **i18n: routed untranslated strings through `t()` (#669).** `FieldInput`'s
78+
`Lookup ↗` / lookup aria-label, the `— select —` / `(none)` placeholders,
79+
and the time / array / range / FK placeholders, plus `App.tsx`'s "Page not
80+
found.", now go through the catalog; the new keys (and the #664 / #665
81+
operator notes) were added to the es / fr / pt catalogs.
82+
1083
## [1.10.1] — 2026-06-02
1184

1285
### Fixed

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,9 @@ all share the v1 wire contract. Per-feature live status below.
618618
| `list_editable` + bulk PATCH ||
619619
| `actions` — batch + detail (signature-classified) ||
620620
| `autocomplete_fields` ||
621-
| `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) |
622-
| `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) |
621+
| `raw_id_fields` (pk text input + lookup popup) ||
622+
| `radio_fields` (inline radio buttons vs `<select>`) ||
623+
| `filter_horizontal` / `filter_vertical` (M2M shuttle) ||
623624
| `ManyToManyField` read + write ||
624625
| `inlines` (TabularInline / StackedInline) — read + write ||
625626
| `FileField` / `ImageField` — read ||
@@ -648,14 +649,13 @@ issues link the work to close each gap.
648649
|---|---|---|
649650
| `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) |
650651
| `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) |
651-
| `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) |
652-
| `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) |
653-
| `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) |
654-
| `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) |
652+
| `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) |
653+
| `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) |
654+
| 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) |
655+
| `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) |
655656
| `GenericForeignKey` / `GenericInlineModelAdmin` | Support gap — verify per-model before relying on the SPA. | [#628](https://github.com/MartinCastroAlvarez/django-admin-react/issues/628) |
656-
| `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) |
657+
| `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) |
657658
| `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) |
658-
| 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) |
659659

660660
If your admin relies on any "silently ignored" hook above, the
661661
typical workaround is to keep that model on the legacy

SECURITY.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ Content-Security-Policy:
275275
connect-src 'self'; # the API is same-origin
276276
manifest-src 'self';
277277
worker-src 'self'; # the PWA service worker
278+
frame-src 'self'; # legacy-admin iframe fallback (#659) — same-origin only
278279
frame-ancestors 'none'; # clickjacking (with X_FRAME_OPTIONS)
279280
base-uri 'self';
280281
form-action 'self';
@@ -291,6 +292,26 @@ Caveats — **validate before enforcing**:
291292
`style` attributes at runtime; it is far lower-risk than allowing
292293
inline scripts. Drop it if you verify your build needs no inline
293294
styles.
295+
- `frame-src 'self'` and the **X-Frame-Options interaction** — the SPA's
296+
legacy-admin fallback (#659) embeds the legacy admin change/add page in
297+
a **same-origin** `<iframe>` when a `ModelAdmin` overrides
298+
`change_form_template` / `add_form_template`. `frame-src 'self'` is the
299+
explicit allowlist for that frame: it both **permits** the intended
300+
same-origin embed and **blocks** an off-origin `legacy_url` (defence in
301+
depth — the SPA also validates the URL client-side as same-origin
302+
http(s) and sandboxes the iframe with `allow-forms allow-scripts
303+
allow-same-origin`). Two interaction notes:
304+
- **`X_FRAME_OPTIONS = "DENY"` will break the iframe** if it is applied
305+
to the legacy admin's *own* responses, because the legacy admin page
306+
is what gets framed. If you use the legacy-iframe fallback, scope
307+
`X-Frame-Options` (and any `frame-ancestors`) so the legacy admin
308+
permits same-origin framing — e.g. set `X_FRAME_OPTIONS = "SAMEORIGIN"`
309+
for the legacy admin responses, or omit the header on that mount.
310+
`frame-ancestors 'none'` on the *SPA shell* is fine — it controls who
311+
may frame the SPA, not what the SPA may frame.
312+
- If you do **not** use a custom `change_form_template` (the SPA renders
313+
every form from the JSON form-spec), no iframe is ever created and you
314+
can drop `frame-src` and keep `X_FRAME_OPTIONS = "DENY"` everywhere.
294315
- This policy assumes the package is mounted on its own path under your
295316
domain. If you already ship a project-wide CSP, **merge** these
296317
directives rather than replacing your policy.

django_admin_react/apps.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from django.apps import AppConfig
9+
from django.core.checks import register
910

1011

1112
class DjangoAdminReactConfig(AppConfig):
@@ -25,3 +26,15 @@ class DjangoAdminReactConfig(AppConfig):
2526
label = "django_admin_react"
2627
verbose_name = "Django Admin React"
2728
default_auto_field = "django.db.models.BigAutoField"
29+
30+
def ready(self) -> None:
31+
"""Register the package's system checks at app-load (#667).
32+
33+
Importing + registering here (not at module import time) keeps the
34+
checks tied to the app registry being ready, matching Django's
35+
documented pattern. The import is local so adding the app has no
36+
eager import cost beyond the AppConfig itself.
37+
"""
38+
from django_admin_react.checks import check_django_admin_react
39+
40+
register(check_django_admin_react)

django_admin_react/checks.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""System checks for django_admin_react (#667).
2+
3+
Registered against ``django.core.checks`` so common misconfigurations
4+
surface at ``manage.py check`` (and on every ``runserver`` boot) with an
5+
actionable hint — instead of a lazy ``ValueError`` on first settings
6+
access, a runtime 500, or a silent template fallback.
7+
8+
What we validate:
9+
10+
- ``django_admin_rest_api`` is installed (the package implements no API of
11+
its own — every JSON endpoint lives in that sibling package).
12+
- ``DJANGO_ADMIN_REACT["ADMIN_SITE"]`` (dotted path) actually imports.
13+
- ``DJANGO_ADMIN_REACT`` has no unknown keys (the same guard ``conf._load``
14+
enforces lazily — surfaced here at startup as a clean check error).
15+
- ``API_URL_PREFIX`` mounting is coherent: when it is set, the package
16+
skips its inline ``api/v1/`` include, so the consumer must mount
17+
``django_admin_rest_api.urls`` themselves — a Warning reminds them.
18+
- The built SPA bundle / Vite manifest exists (a Warning, not an Error —
19+
the package serves a friendly "not built" shell, and the manifest is
20+
absent in a source checkout / before ``pnpm build``).
21+
22+
Severities follow Django's convention: an ``Error`` blocks (the package
23+
cannot work); a ``Warning`` is a likely-misconfiguration heads-up that
24+
does not hard-block.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from typing import Any
30+
31+
from django.core.checks import Error
32+
from django.core.checks import Warning as CheckWarning
33+
34+
# Stable check IDs (Django convention ``<app_label>.E/W###``) so a consumer
35+
# can silence a specific one via ``SILENCED_SYSTEM_CHECKS`` if they must.
36+
ID_REST_API_MISSING = "django_admin_react.E001"
37+
ID_ADMIN_SITE_IMPORT = "django_admin_react.E002"
38+
ID_UNKNOWN_SETTINGS = "django_admin_react.E003"
39+
ID_API_PREFIX_MOUNT = "django_admin_react.W001"
40+
ID_BUNDLE_MISSING = "django_admin_react.W002"
41+
42+
43+
def check_django_admin_react(app_configs: Any, **kwargs: Any) -> list[Any]:
44+
"""Run every package configuration check.
45+
46+
Registered as a single callable (rather than many) so the import
47+
surface stays small and the ordering is explicit. ``app_configs`` /
48+
``kwargs`` are the Django check-framework signature; unused here
49+
because the checks are global to the package, not per-app.
50+
"""
51+
errors: list[Any] = []
52+
errors.extend(_check_rest_api_installed())
53+
errors.extend(_check_admin_site_imports())
54+
errors.extend(_check_settings_keys())
55+
errors.extend(_check_api_prefix_coherence())
56+
errors.extend(_check_bundle_built())
57+
return errors
58+
59+
60+
def _check_rest_api_installed() -> list[Any]:
61+
from django.apps import apps as django_apps
62+
63+
if django_apps.is_installed("django_admin_rest_api"):
64+
return []
65+
return [
66+
Error(
67+
"'django_admin_rest_api' is not in INSTALLED_APPS.",
68+
hint=(
69+
"django-admin-react is a React SPA over django-admin-rest-api "
70+
"and implements no API of its own. Add 'django_admin_rest_api' "
71+
"to INSTALLED_APPS (it ships as a dependency)."
72+
),
73+
id=ID_REST_API_MISSING,
74+
)
75+
]
76+
77+
78+
def _check_admin_site_imports() -> list[Any]:
79+
from django.utils.module_loading import import_string
80+
81+
from django_admin_react import conf as dar_conf
82+
83+
dotted = dar_conf.ADMIN_SITE
84+
try:
85+
import_string(dotted)
86+
except ImportError as exc:
87+
return [
88+
Error(
89+
f"DJANGO_ADMIN_REACT['ADMIN_SITE'] = {dotted!r} could not be imported " f"({exc}).",
90+
hint=(
91+
"Point ADMIN_SITE at the dotted path of your AdminSite "
92+
"instance (e.g. 'myproject.admin.site' or the default "
93+
"'django.contrib.admin.site')."
94+
),
95+
id=ID_ADMIN_SITE_IMPORT,
96+
)
97+
]
98+
return []
99+
100+
101+
def _check_settings_keys() -> list[Any]:
102+
from django.conf import settings as django_settings
103+
104+
from django_admin_react.conf import DEFAULTS
105+
106+
overrides = getattr(django_settings, "DJANGO_ADMIN_REACT", {}) or {}
107+
unknown = sorted(set(overrides) - set(DEFAULTS))
108+
if not unknown:
109+
return []
110+
return [
111+
Error(
112+
"Unknown DJANGO_ADMIN_REACT key(s): " + ", ".join(unknown) + ".",
113+
hint=("Remove the typo'd key(s). Valid keys are: " + ", ".join(sorted(DEFAULTS)) + "."),
114+
id=ID_UNKNOWN_SETTINGS,
115+
)
116+
]
117+
118+
119+
def _check_api_prefix_coherence() -> list[Any]:
120+
from django_admin_react import conf as dar_conf
121+
122+
prefix = dar_conf.API_URL_PREFIX
123+
if prefix is None:
124+
# Inline mount is active; the package mounts the API itself. Nothing
125+
# the consumer must do.
126+
return []
127+
# The override is set, so `django_admin_react.urls` skips the inline
128+
# `api/v1/` include — the consumer MUST mount the REST API themselves at
129+
# that prefix or every SPA data call 404s.
130+
return [
131+
CheckWarning(
132+
f"DJANGO_ADMIN_REACT['API_URL_PREFIX'] = {prefix!r} is set, so this "
133+
"package does NOT mount the REST API inline.",
134+
hint=(
135+
"Mount the sibling API yourself at that prefix, e.g.\n"
136+
f" path({prefix!r}, include('django_admin_rest_api.api.urls'))\n"
137+
"or unset API_URL_PREFIX to use the package's inline 'api/v1/' mount."
138+
),
139+
id=ID_API_PREFIX_MOUNT,
140+
)
141+
]
142+
143+
144+
def _check_bundle_built() -> list[Any]:
145+
# Imported lazily so the check module has no import-time dependency on
146+
# the view layer.
147+
from django_admin_react.views import _MANIFEST_PATH
148+
149+
if _MANIFEST_PATH.is_file():
150+
return []
151+
return [
152+
CheckWarning(
153+
"The built SPA bundle / Vite manifest was not found at " f"{_MANIFEST_PATH}.",
154+
hint=(
155+
"Install the published wheel (which ships the built bundle), or "
156+
"build the frontend from source with 'pnpm --dir frontend build'. "
157+
"Until then the SPA shell renders a 'not built yet' page."
158+
),
159+
id=ID_BUNDLE_MISSING,
160+
)
161+
]

0 commit comments

Comments
 (0)