Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7db7eb0e8 | |||
| b68a131bae | |||
| 88cf374f33 | |||
| be919c992d |
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
|
|||||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||||
tests/ — Pytest tests
|
tests/ — Pytest tests
|
||||||
|
e2e/ — Playwright browser tests (run via `make test-e2e`)
|
||||||
contrib/ — One-off scripts (exchange rate import)
|
contrib/ — One-off scripts (exchange rate import)
|
||||||
docs/ — Additional documentation
|
docs/ — Additional documentation
|
||||||
```
|
```
|
||||||
@@ -113,13 +114,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
|||||||
### Frontend stack
|
### Frontend stack
|
||||||
|
|
||||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||||
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||||
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
|
||||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||||
|
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
|
||||||
- **Custom JS** in `games/static/js/`:
|
- **Custom JS** in `games/static/js/`:
|
||||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
|
||||||
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||||
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
|
||||||
|
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
@@ -155,6 +158,8 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
|
|||||||
|
|
||||||
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
||||||
|
|
||||||
|
**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
|
||||||
|
|
||||||
## Conventions for AI assistants
|
## Conventions for AI assistants
|
||||||
|
|
||||||
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
|
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ from common.components.primitives import (
|
|||||||
SearchField,
|
SearchField,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
Span,
|
Span,
|
||||||
|
StaticScript,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableTd,
|
TableTd,
|
||||||
@@ -112,6 +113,7 @@ __all__ = [
|
|||||||
"searchselect_selected",
|
"searchselect_selected",
|
||||||
"SimpleTable",
|
"SimpleTable",
|
||||||
"Span",
|
"Span",
|
||||||
|
"StaticScript",
|
||||||
"Label",
|
"Label",
|
||||||
"TableHeader",
|
"TableHeader",
|
||||||
"TableRow",
|
"TableRow",
|
||||||
|
|||||||
@@ -559,9 +559,11 @@ def _filter_collapse_button() -> SafeText:
|
|||||||
tag_name="button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
|
# Slider handles are positioned in percentages, so initializing
|
||||||
|
# them while the body is hidden is safe — no re-init on reveal.
|
||||||
(
|
(
|
||||||
"onclick",
|
"onclick",
|
||||||
"var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
|
"document.getElementById('filter-bar-body').classList.toggle('hidden')",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
|
|||||||
@@ -554,6 +554,12 @@ def ExternalScript(url: str) -> SafeText:
|
|||||||
return mark_safe(f'<script src="{url}"></script>')
|
return mark_safe(f'<script src="{url}"></script>')
|
||||||
|
|
||||||
|
|
||||||
|
def StaticScript(filename: str) -> SafeText:
|
||||||
|
"""A plain (classic, non-module) `<script src=...>` tag for a static JS
|
||||||
|
file — for vendored UMD bundles, which break inside module scope."""
|
||||||
|
return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
|
||||||
|
|
||||||
|
|
||||||
def YearPicker(
|
def YearPicker(
|
||||||
year: int | None = None,
|
year: int | None = None,
|
||||||
available_years: tuple[int, ...] = (),
|
available_years: tuple[int, ...] = (),
|
||||||
|
|||||||
+6
-3
@@ -309,9 +309,12 @@ def Page(
|
|||||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||||
f" {django_htmx_script(nonce=None)}\n"
|
f" {django_htmx_script(nonce=None)}\n"
|
||||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||||
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
||||||
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
# served locally so pages work offline (and in browser tests). The mask
|
||||||
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
# plugin must load before Alpine core; both stay deferred.
|
||||||
|
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
|
||||||
|
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
|
||||||
|
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
|
||||||
f" {_THEME_FOUC_SCRIPT}\n"
|
f" {_THEME_FOUC_SCRIPT}\n"
|
||||||
" </head>\n"
|
" </head>\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,35 +4,43 @@ from django.http import HttpResponse
|
|||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from common.components import SearchSelect
|
from common.components import SearchSelect
|
||||||
|
|
||||||
|
|
||||||
def e2e_test_view(request):
|
def e2e_test_view(request):
|
||||||
html = f"""
|
html = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>SearchSelect E2E Test</title>
|
<title>SearchSelect E2E Test</title>
|
||||||
<script src="/static/js/search_select.js" defer></script>
|
<!-- search_select.js is an ES module and initializes via onSwap(),
|
||||||
|
which rides on htmx.onLoad — so htmx must be present. -->
|
||||||
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
|
<script type="module" src="/static/js/search_select.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="padding: 50px;">
|
<div style="padding: 50px;">
|
||||||
{SearchSelect(
|
{
|
||||||
|
SearchSelect(
|
||||||
name="games",
|
name="games",
|
||||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||||
options=[
|
options=[
|
||||||
{"value": "7", "label": "Game A", "data": {}},
|
{"value": "7", "label": "Game A", "data": {}},
|
||||||
{"value": "8", "label": "Game B", "data": {}},
|
{"value": "8", "label": "Game B", "data": {}},
|
||||||
],
|
],
|
||||||
multi_select=False
|
multi_select=False,
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("test-search-select/", e2e_test_view),
|
path("test-search-select/", e2e_test_view),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
|
||||||
|
add_purchase.js) and their onSwap() initialization lifecycle.
|
||||||
|
|
||||||
|
These run a real Chromium via pytest-playwright against pytest-django's
|
||||||
|
``live_server``. All JavaScript under test is served locally from
|
||||||
|
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
|
||||||
|
vendored), so no network access is needed beyond the live server itself.
|
||||||
|
|
||||||
|
Browser binaries must be installed once: ``uv run playwright install chromium``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||||
|
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||||
|
page.goto(f"{live_server.url}{reverse('login')}")
|
||||||
|
page.fill('input[name="username"]', "tester")
|
||||||
|
page.fill('input[name="password"]', "secret123")
|
||||||
|
page.click('input[type="submit"]')
|
||||||
|
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def open_filter_bar(page: Page) -> None:
|
||||||
|
page.click("#filter-bar button:has-text('Filters')")
|
||||||
|
expect(page.locator("#filter-bar-body")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def status_filter_widget(page: Page):
|
||||||
|
return page.locator('[data-search-select][data-name="status"]')
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
|
||||||
|
"""Clicking into a FilterSelect search box opens its options panel —
|
||||||
|
proof that onSwap ran the widget initializer on the initial page load."""
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||||
|
open_filter_bar(page)
|
||||||
|
|
||||||
|
widget = status_filter_widget(page)
|
||||||
|
widget.locator("[data-search-select-search]").click()
|
||||||
|
|
||||||
|
options_panel = widget.locator("[data-search-select-options]")
|
||||||
|
expect(options_panel).to_be_visible()
|
||||||
|
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
|
||||||
|
# only becomes interactable through the initialized panel.
|
||||||
|
expect(
|
||||||
|
options_panel.locator("[data-search-select-modifier-option]").first
|
||||||
|
).to_have_text("(Any)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
|
||||||
|
"""Clicking an enum option row adds an include pill (full widget wiring)."""
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||||
|
open_filter_bar(page)
|
||||||
|
|
||||||
|
widget = status_filter_widget(page)
|
||||||
|
widget.locator("[data-search-select-search]").click()
|
||||||
|
widget.locator('[data-search-select-option][data-label="Finished"]').click()
|
||||||
|
|
||||||
|
pill = widget.locator("[data-search-select-pills] [data-pill]")
|
||||||
|
expect(pill).to_have_count(1)
|
||||||
|
expect(pill).to_contain_text("Finished")
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_slider_mode_toggle_fires_exactly_once(
|
||||||
|
authenticated_page: Page, live_server
|
||||||
|
):
|
||||||
|
"""One click on the mode toggle flips the slider from range to point mode
|
||||||
|
exactly once. Double-bound listeners (the old force-re-init bug) would
|
||||||
|
flip it twice, leaving data-mode unchanged."""
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||||
|
open_filter_bar(page)
|
||||||
|
|
||||||
|
block = page.locator(".range-slider-block").first
|
||||||
|
slider = block.locator(".range-slider")
|
||||||
|
expect(slider).to_have_attribute("data-mode", "range")
|
||||||
|
|
||||||
|
block.locator(".range-mode-toggle").click()
|
||||||
|
expect(slider).to_have_attribute("data-mode", "point")
|
||||||
|
|
||||||
|
|
||||||
|
def test_widgets_initialize_inside_htmx_swapped_content(
|
||||||
|
authenticated_page: Page, live_server
|
||||||
|
):
|
||||||
|
"""Widgets arriving via an htmx swap initialize without a page load.
|
||||||
|
|
||||||
|
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
|
||||||
|
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
|
||||||
|
swapped-in slider must toggle exactly once, proving the htmx:load half of
|
||||||
|
onSwap and the once-per-element guard."""
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"htmx.ajax('GET', window.location.pathname, "
|
||||||
|
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
|
||||||
|
)
|
||||||
|
# The swapped-in bar arrives collapsed again; opening it proves the swap
|
||||||
|
# happened and the fresh DOM is in place.
|
||||||
|
open_filter_bar(page)
|
||||||
|
|
||||||
|
widget = status_filter_widget(page)
|
||||||
|
widget.locator("[data-search-select-search]").click()
|
||||||
|
expect(widget.locator("[data-search-select-options]")).to_be_visible()
|
||||||
|
|
||||||
|
block = page.locator(".range-slider-block").first
|
||||||
|
slider = block.locator(".range-slider")
|
||||||
|
expect(slider).to_have_attribute("data-mode", "range")
|
||||||
|
block.locator(".range-mode-toggle").click()
|
||||||
|
expect(slider).to_have_attribute("data-mode", "point")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_purchase_type_toggles_disabled_fields(
|
||||||
|
authenticated_page: Page, live_server
|
||||||
|
):
|
||||||
|
"""add_purchase.js disables name/related-purchase while type is "game"
|
||||||
|
and re-enables them for other types."""
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||||
|
|
||||||
|
name_input = page.locator("#id_name")
|
||||||
|
expect(name_input).to_be_disabled()
|
||||||
|
|
||||||
|
page.select_option("#id_type", "dlc")
|
||||||
|
expect(name_input).to_be_enabled()
|
||||||
|
|
||||||
|
page.select_option("#id_type", "game")
|
||||||
|
expect(name_input).to_be_disabled()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getEl, disableElementsWhenTrue } from "./utils.js";
|
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||||
|
|
||||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||||
|
|
||||||
@@ -38,8 +38,9 @@ function setupElementHandlers() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
onSwap("#id_type", (typeSelect) => {
|
||||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
setupElementHandlers();
|
||||||
getEl("#id_type").addEventListener("change", () => {
|
typeSelect.addEventListener("change", () => {
|
||||||
setupElementHandlers();
|
setupElementHandlers();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l<n.length&&r<e.length;){let i=n[l],u=e[r];i in _?(_[i].test(u)&&(s+=u,l++),r++):(s+=i,l++,i===e[r]&&r++)}return s}function M(n,e=".",l,r=2){if(n==="-")return"-";if(/^\D+$/.test(n))return"9";l==null&&(l=e===","?".":",");let s=(f,g)=>{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
|
||||||
Vendored
+5
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@
|
|||||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||||
* No HTMX — plain fetch() and window.location for all interactions.
|
* No HTMX — plain fetch() and window.location for all interactions.
|
||||||
*/
|
*/
|
||||||
|
import { onSwap } from "./utils.js";
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -410,9 +412,8 @@
|
|||||||
|
|
||||||
// ── Init on page load ───────────────────────────────────────────────────
|
// ── Init on page load ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── Inject search inputs into filter forms ──
|
// ── Inject the search input into a filter form ──
|
||||||
function injectSearchInputs() {
|
function injectSearchInput(form) {
|
||||||
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
|
||||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||||
var input = document.createElement("input");
|
var input = document.createElement("input");
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
@@ -430,7 +431,6 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -470,8 +470,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
onSwap('[id^="filter-bar-form"]', function (form) {
|
||||||
injectSearchInputs();
|
injectSearchInput(form);
|
||||||
setupDeselectableRadios();
|
setupDeselectableRadios();
|
||||||
setupStringFilters();
|
setupStringFilters();
|
||||||
loadPresets();
|
loadPresets();
|
||||||
|
|||||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -8,15 +8,12 @@
|
|||||||
* Handles track-fill positioning and sync between handles and the connected
|
* Handles track-fill positioning and sync between handles and the connected
|
||||||
* number inputs (linked via data-target attributes).
|
* number inputs (linked via data-target attributes).
|
||||||
*/
|
*/
|
||||||
|
import { onSwap } from "./utils.js";
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function initAll(force) {
|
function initializeSlider(slider) {
|
||||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
|
||||||
if (force) slider._rsInit = false;
|
|
||||||
if (slider._rsInit) return;
|
|
||||||
slider._rsInit = true;
|
|
||||||
|
|
||||||
var mode = slider.getAttribute("data-mode") || "range";
|
var mode = slider.getAttribute("data-mode") || "range";
|
||||||
var trackFill = slider.querySelector(".range-track-fill");
|
var trackFill = slider.querySelector(".range-track-fill");
|
||||||
var minHandle = slider.querySelector(".range-handle-min");
|
var minHandle = slider.querySelector(".range-handle-min");
|
||||||
@@ -227,10 +224,7 @@
|
|||||||
|
|
||||||
// ── Initial position ──
|
// ── Initial position ──
|
||||||
updateHandles();
|
updateHandles();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", initAll);
|
onSwap(".range-slider", initializeSlider);
|
||||||
document.addEventListener("htmx:afterSwap", initAll);
|
|
||||||
window.initRangeSliders = initAll;
|
|
||||||
})();
|
})();
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||||
*
|
*
|
||||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
||||||
* element._searchSelectInit.
|
* page load and every htmx-swapped fragment, once per widget.
|
||||||
*
|
*
|
||||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||||
* the server renders with the same Python components (Pill / SearchSelect /
|
* the server renders with the same Python components (Pill / SearchSelect /
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||||
* place (the Python components), never duplicated here.
|
* place (the Python components), never duplicated here.
|
||||||
*/
|
*/
|
||||||
|
import { onSwap } from "./utils.js";
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -32,14 +34,6 @@
|
|||||||
// INCLUDES_ONLY) coexist with value pills.
|
// INCLUDES_ONLY) coexist with value pills.
|
||||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||||
|
|
||||||
const initAll = () => {
|
|
||||||
document.querySelectorAll("[data-search-select]").forEach(element => {
|
|
||||||
if (element._searchSelectInit) return;
|
|
||||||
element._searchSelectInit = true;
|
|
||||||
initWidget(element);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const initWidget = (container) => {
|
const initWidget = (container) => {
|
||||||
const search = container.querySelector("[data-search-select-search]");
|
const search = container.querySelector("[data-search-select-search]");
|
||||||
const options = container.querySelector("[data-search-select-options]");
|
const options = container.querySelector("[data-search-select-options]");
|
||||||
@@ -666,6 +660,5 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", initAll);
|
onSwap("[data-search-select]", initWidget);
|
||||||
document.addEventListener("htmx:afterSwap", initAll);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @description Runs initializeElement once for each element matching selector,
|
||||||
|
* on initial page load and inside every htmx-swapped fragment (a port of
|
||||||
|
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
|
||||||
|
* each swapped-in element, so a single registration covers both; the WeakSet
|
||||||
|
* guarantees once-per-element initialization, replacing the old
|
||||||
|
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
|
||||||
|
* @param {string} selector
|
||||||
|
* @param {function(Element): void} initializeElement
|
||||||
|
*/
|
||||||
|
function onSwap(selector, initializeElement) {
|
||||||
|
const initialized = new WeakSet();
|
||||||
|
htmx.onLoad((swappedElement) => {
|
||||||
|
const elements = Array.from(htmx.findAll(swappedElement, selector));
|
||||||
|
if (swappedElement.matches && swappedElement.matches(selector)) {
|
||||||
|
elements.unshift(swappedElement);
|
||||||
|
}
|
||||||
|
for (const element of elements) {
|
||||||
|
if (initialized.has(element)) continue;
|
||||||
|
initialized.add(element);
|
||||||
|
initializeElement(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
onSwap,
|
||||||
toISOUTCString,
|
toISOUTCString,
|
||||||
syncSelectInputUntilChanged,
|
syncSelectInputUntilChanged,
|
||||||
getEl,
|
getEl,
|
||||||
|
|||||||
@@ -13,17 +13,16 @@ 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 ExternalScript
|
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, hoisted into the stats pages for YearPicker.
|
# Flowbite-datepicker UMD bundle (vendored, v2.0.0), hoisted into the stats
|
||||||
_STATS_SCRIPTS = ExternalScript(
|
# pages for YearPicker.
|
||||||
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
|
_STATS_SCRIPTS = StaticScript("datepicker.umd.js")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ pkgs.mkShell {
|
|||||||
pnpm
|
pnpm
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# manylinux wheels with native extensions (greenlet, pulled in by
|
||||||
|
# pytest-playwright) link against libstdc++.so.6, which the nixpkgs
|
||||||
|
# Python cannot find on its default search path. Scoped to this dev
|
||||||
|
# shell only — a global LD_LIBRARY_PATH would leak into other programs.
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
uv venv --clear
|
uv venv --clear
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
|
|||||||
Reference in New Issue
Block a user