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:
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
|
||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
e2e/ — Playwright browser tests (run via `make test-e2e`)
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
docs/ — Additional documentation
|
||||
```
|
||||
@@ -113,13 +114,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
||||
### Frontend stack
|
||||
|
||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
||||
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **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`
|
||||
- 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/`:
|
||||
- `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)
|
||||
- `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
|
||||
|
||||
@@ -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"`).
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -559,9 +559,11 @@ def _filter_collapse_button() -> SafeText:
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
# Slider handles are positioned in percentages, so initializing
|
||||
# them while the body is hidden is safe — no re-init on reveal.
|
||||
(
|
||||
"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",
|
||||
|
||||
@@ -4,35 +4,43 @@ from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from common.components import SearchSelect
|
||||
|
||||
|
||||
def e2e_test_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{SearchSelect(
|
||||
{
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False
|
||||
)}
|
||||
multi_select=False,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-search-select/", e2e_test_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
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";
|
||||
|
||||
@@ -38,8 +38,9 @@ function setupElementHandlers() {
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
onSwap("#id_type", (typeSelect) => {
|
||||
setupElementHandlers();
|
||||
typeSelect.addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||
* No HTMX — plain fetch() and window.location for all interactions.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
@@ -410,9 +412,8 @@
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── Inject search inputs into filter forms ──
|
||||
function injectSearchInputs() {
|
||||
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
||||
// ── Inject the search input into a filter form ──
|
||||
function injectSearchInput(form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
@@ -430,7 +431,6 @@
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,8 +470,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
onSwap('[id^="filter-bar-form"]', function (form) {
|
||||
injectSearchInput(form);
|
||||
setupDeselectableRadios();
|
||||
setupStringFilters();
|
||||
loadPresets();
|
||||
|
||||
@@ -8,15 +8,12 @@
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes).
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll(force) {
|
||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||
if (force) slider._rsInit = false;
|
||||
if (slider._rsInit) return;
|
||||
slider._rsInit = true;
|
||||
|
||||
function initializeSlider(slider) {
|
||||
var mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
@@ -227,10 +224,7 @@
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
window.initRangeSliders = initAll;
|
||||
onSwap(".range-slider", initializeSlider);
|
||||
})();
|
||||
@@ -12,8 +12,8 @@
|
||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||
* element._searchSelectInit.
|
||||
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
||||
* page load and every htmx-swapped fragment, once per widget.
|
||||
*
|
||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||
* 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
|
||||
* place (the Python components), never duplicated here.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
@@ -32,14 +34,6 @@
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
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 search = container.querySelector("[data-search-select-search]");
|
||||
const options = container.querySelector("[data-search-select-options]");
|
||||
@@ -666,6 +660,5 @@
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
onSwap("[data-search-select]", initWidget);
|
||||
})();
|
||||
|
||||
@@ -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.
|
||||
* @param {Date} date
|
||||
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
}
|
||||
|
||||
export {
|
||||
onSwap,
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
|
||||
Reference in New Issue
Block a user