Initialize widget JS via onSwap helper

Port FastHTML's proc_htmx as onSwap(selector, initializeElement) in
utils.js, built on htmx.onLoad: it runs an initializer once per matching
element, on initial page load and inside every htmx-swapped fragment.

Migrate search_select.js, range_slider.js, filter_bar.js and
add_purchase.js to it, removing the hand-rolled DOMContentLoaded +
htmx:afterSwap listeners and per-element guard flags. This also fixes a
latent bug: both events passed the Event object as range_slider's
"force" parameter, so every htmx swap force-re-initialized all sliders
and stacked duplicate listeners. The collapse button's
window.initRangeSliders() call was a no-op (handles are positioned in
percentages, so hidden-init is safe) and is removed with the global.

Add e2e/test_widgets_e2e.py covering the onSwap lifecycle (initial-load
init, htmx-swap init, single-fire toggles) plus FilterSelect pills and
the add-purchase type toggle. The synthetic page in
test_search_select_e2e.py now loads htmx and search_select.js as a
module, matching the new initialization path.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-12 21:18:36 +00:00
parent 88cf374f33
commit b68a131bae
9 changed files with 440 additions and 278 deletions
+8 -5
View File
@@ -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,7 +158,7 @@ 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**: `pytest-playwright` is a dev dependency for testing JavaScript behavior in a real browser (combine pytest-django's `live_server` fixture with Playwright's `page` fixture). Browser binaries must be installed once via `uv run playwright install chromium`. Note: pages load Alpine.js and Flowbite from CDNs, so browser tests must not depend on CDN-served scripts when running offline (htmx and all widget JS are served locally from `games/static/js/`). **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
+3 -1
View File
@@ -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",
+12 -4
View File
@@ -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):
+135
View File
@@ -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()
+5 -4
View File
@@ -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();
}); });
});
+6 -6
View File
@@ -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();
+4 -10
View File
@@ -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;
})(); })();
+5 -12
View File
@@ -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);
})(); })();
+26
View File
@@ -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,