Phase 4: Page() collects component media; drop manual scripts= threading
Page() now calls collect_media(content) and emits the ModuleScript / StaticScript tags itself, so views no longer thread scripts= for component-owned JS. The list views (game/session/purchase/device/ platform/playevent) compose with Fragment(filter_bar, content) instead of mark_safe(str(filter_bar) + str(content)) — keeping the node tree intact so the filter bar's media (filter_bar.js + search_select.js + range_slider.js, and date_range_picker.js on purchases) reaches Page(). The stats views drop _STATS_SCRIPTS; YearPicker's datepicker.umd.js is collected from its declared media. The scripts= argument remains for page-specific glue not owned by a component (the add-form helpers add_game.js / add_purchase.js / add_session.js, alongside search_select.js for their form widgets). Adds regression tests asserting the list and stats pages auto-load their widget scripts with no scripts= in the view, and documents the node/ media model in CLAUDE.md. https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
@@ -58,12 +58,12 @@ docs/ — Additional documentation
|
|||||||
|
|
||||||
### Key patterns
|
### Key patterns
|
||||||
|
|
||||||
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
|
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
|
||||||
|
|
||||||
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
|
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
|
||||||
|
|
||||||
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
|
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped; children escaped unless `SafeText`/`Node`. The legacy `Component(tag_name=...)` function still returns a `SafeText` string (Node-aware children) for back-compat. `randomid()` generates stable hash-based IDs.
|
||||||
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
|
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
|
||||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||||
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
||||||
@@ -166,7 +166,8 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
|
|||||||
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
||||||
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
||||||
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
||||||
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
|
- **Components are nodes; use the named builders, not the legacy `Component()`** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue). The legacy `Component(tag_name=…)` function is retained for back-compat only.
|
||||||
|
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
||||||
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
||||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||||
|
|||||||
+16
-2
@@ -276,9 +276,23 @@ def Page(
|
|||||||
scripts: SafeText | str = "",
|
scripts: SafeText | str = "",
|
||||||
mastered: bool = False,
|
mastered: bool = False,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
"""Assemble a full HTML document around `content` (the fast_app equivalent).
|
||||||
|
|
||||||
|
Scripts are collected from `content`'s component tree: every component
|
||||||
|
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
|
||||||
|
for the whole page. The `scripts` argument remains for page-specific glue
|
||||||
|
that isn't owned by a reusable component (e.g. the add-form helpers).
|
||||||
|
"""
|
||||||
|
from common.components import ModuleScript, StaticScript, collect_media
|
||||||
from games.views.general import global_current_year, model_counts
|
from games.views.general import global_current_year, model_counts
|
||||||
|
|
||||||
|
media = collect_media(content)
|
||||||
|
collected_scripts = "".join(
|
||||||
|
[str(ModuleScript(name)) for name in media.js]
|
||||||
|
+ [str(StaticScript(name)) for name in media.js_external]
|
||||||
|
)
|
||||||
|
all_scripts = collected_scripts + (str(scripts) if scripts else "")
|
||||||
|
|
||||||
counts = model_counts(request)
|
counts = model_counts(request)
|
||||||
year = global_current_year(request)["global_current_year"]
|
year = global_current_year(request)["global_current_year"]
|
||||||
navbar = Navbar(
|
navbar = Navbar(
|
||||||
@@ -328,7 +342,7 @@ def Page(
|
|||||||
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||||
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||||
" </div>\n"
|
" </div>\n"
|
||||||
f" {scripts}\n"
|
f" {all_scripts}\n"
|
||||||
f" {_main_script(mastered)}\n"
|
f" {_main_script(mastered)}\n"
|
||||||
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
|
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
|
||||||
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
Fragment,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
@@ -12,7 +12,6 @@ from common.components import (
|
|||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
DeviceFilterBar,
|
DeviceFilterBar,
|
||||||
ModuleScript,
|
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
@@ -76,14 +75,11 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||||
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||||
)
|
)
|
||||||
content = mark_safe(str(filter_bar) + str(content))
|
content = Fragment(filter_bar, content)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage devices",
|
title="Manage devices",
|
||||||
scripts=ModuleScript("range_slider.js")
|
|
||||||
+ ModuleScript("search_select.js")
|
|
||||||
+ ModuleScript("filter_bar.js"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -11,6 +11,7 @@ from django.urls import reverse
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
Fragment,
|
||||||
H1,
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
@@ -145,14 +146,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets"),
|
preset_list_url=reverse("games:list_presets"),
|
||||||
preset_save_url=reverse("games:save_preset"),
|
preset_save_url=reverse("games:save_preset"),
|
||||||
)
|
)
|
||||||
content = mark_safe(str(filter_bar) + str(content))
|
content = Fragment(filter_bar, content)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage games",
|
title="Manage games",
|
||||||
scripts=ModuleScript("range_slider.js")
|
|
||||||
+ ModuleScript("search_select.js")
|
|
||||||
+ ModuleScript("filter_bar.js"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -13,16 +13,14 @@ from django.urls import reverse
|
|||||||
from django.utils.timezone import localtime
|
from django.utils.timezone import localtime
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
|
||||||
from common.components import StaticScript
|
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import format_duration
|
from common.time import format_duration
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Game, Platform, Purchase, Session
|
||||||
from games.views.stats_content import stats_content
|
from games.views.stats_content import stats_content
|
||||||
from games.views.stats_data import compute_stats
|
from games.views.stats_data import compute_stats
|
||||||
|
|
||||||
# Flowbite-datepicker UMD bundle (vendored, v2.0.0), hoisted into the stats
|
# The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
|
||||||
# pages for YearPicker.
|
# component, so Page() loads it automatically on the stats pages.
|
||||||
_STATS_SCRIPTS = StaticScript("datepicker.umd.js")
|
|
||||||
|
|
||||||
|
|
||||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||||
@@ -76,9 +74,7 @@ def use_custom_redirect(
|
|||||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
data = compute_stats(None)
|
data = compute_stats(None)
|
||||||
return render_page(
|
return render_page(request, stats_content(data), title=data["title"])
|
||||||
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -92,9 +88,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
data = compute_stats(year)
|
data = compute_stats(year)
|
||||||
return render_page(
|
return render_page(request, stats_content(data), title=data["title"])
|
||||||
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
Fragment,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
@@ -12,7 +12,6 @@ from common.components import (
|
|||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
PlatformFilterBar,
|
PlatformFilterBar,
|
||||||
ModuleScript,
|
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
@@ -83,14 +82,11 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||||
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||||
)
|
)
|
||||||
content = mark_safe(str(filter_bar) + str(content))
|
content = Fragment(filter_bar, content)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage platforms",
|
title="Manage platforms",
|
||||||
scripts=ModuleScript("range_slider.js")
|
|
||||||
+ ModuleScript("search_select.js")
|
|
||||||
+ ModuleScript("filter_bar.js"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
Fragment,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
@@ -151,14 +151,11 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||||
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||||
)
|
)
|
||||||
content = mark_safe(str(filter_bar) + str(content))
|
content = Fragment(filter_bar, content)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage play events",
|
title="Manage play events",
|
||||||
scripts=ModuleScript("range_slider.js")
|
|
||||||
+ ModuleScript("search_select.js")
|
|
||||||
+ ModuleScript("filter_bar.js"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.utils.safestring import SafeText, mark_safe
|
|||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
Fragment,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
@@ -129,22 +130,18 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
from common.components import ModuleScript, PurchaseFilterBar
|
from common.components import PurchaseFilterBar
|
||||||
|
|
||||||
filter_bar = PurchaseFilterBar(
|
filter_bar = PurchaseFilterBar(
|
||||||
filter_json=filter_json,
|
filter_json=filter_json,
|
||||||
preset_list_url=reverse("games:list_presets"),
|
preset_list_url=reverse("games:list_presets"),
|
||||||
preset_save_url=reverse("games:save_preset"),
|
preset_save_url=reverse("games:save_preset"),
|
||||||
)
|
)
|
||||||
content = mark_safe(str(filter_bar) + str(content))
|
content = Fragment(filter_bar, content)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage purchases",
|
title="Manage purchases",
|
||||||
scripts=ModuleScript("range_slider.js")
|
|
||||||
+ ModuleScript("search_select.js")
|
|
||||||
+ ModuleScript("date_range_picker.js")
|
|
||||||
+ ModuleScript("filter_bar.js"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from django.utils import timezone
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
Fragment,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
@@ -176,14 +177,11 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
preset_list_url=reverse("games:list_presets"),
|
preset_list_url=reverse("games:list_presets"),
|
||||||
preset_save_url=reverse("games:save_preset"),
|
preset_save_url=reverse("games:save_preset"),
|
||||||
)
|
)
|
||||||
content = mark_safe(str(filter_bar) + str(content))
|
content = Fragment(filter_bar, content)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage sessions",
|
title="Manage sessions",
|
||||||
scripts=ModuleScript("range_slider.js")
|
|
||||||
+ ModuleScript("search_select.js")
|
|
||||||
+ ModuleScript("filter_bar.js"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ class RenderedPagesTest(TestCase):
|
|||||||
marker, html, f"Found double-escaped markup ({marker!r}) in output"
|
marker, html, f"Found double-escaped markup ({marker!r}) in output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- scripts auto-collected from component media (Phase 4) ---------------
|
||||||
|
|
||||||
|
def test_list_page_auto_loads_widget_scripts(self):
|
||||||
|
"""The games list view passes no scripts= argument; the filter bar's
|
||||||
|
components declare their JS and Page() collects it."""
|
||||||
|
html = self.get("games:list_games").content.decode()
|
||||||
|
self.assertIn("js/filter_bar.js", html)
|
||||||
|
self.assertIn("js/search_select.js", html)
|
||||||
|
self.assertIn("js/range_slider.js", html)
|
||||||
|
|
||||||
|
def test_stats_page_auto_loads_datepicker(self):
|
||||||
|
"""YearPicker declares the datepicker UMD bundle as media; the stats
|
||||||
|
view no longer hoists it by hand."""
|
||||||
|
html = self.get("games:stats_alltime").content.decode()
|
||||||
|
self.assertIn("js/datepicker.umd.js", html)
|
||||||
|
|
||||||
# --- layout wrapper ------------------------------------------------------
|
# --- layout wrapper ------------------------------------------------------
|
||||||
|
|
||||||
def test_page_layout_wrapper(self):
|
def test_page_layout_wrapper(self):
|
||||||
@@ -395,15 +411,12 @@ class PurchaseListDateFilterTest(TestCase):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
|
||||||
'value=""',
|
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_date_refunded_not_null(self):
|
def test_date_refunded_not_null(self):
|
||||||
response = self._get(
|
response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
|
||||||
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertNotIn("EARLY-MARKER", html)
|
self.assertNotIn("EARLY-MARKER", html)
|
||||||
|
|||||||
Reference in New Issue
Block a user