From be919c992d87f954d2fd2e90e98a6b63e5ebf23a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:02:01 +0000 Subject: [PATCH 01/20] Document pytest-playwright browser testing in CLAUDE.md https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 298af56..abef602 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,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"`). +**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/`). + ## 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. From 88cf374f3342b3b941a4aef096c4b954e2d7c9c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:18:35 +0000 Subject: [PATCH 02/20] Vendor Alpine, Flowbite and Datepicker bundles locally Serve alpinejs 3.15.12, @alpinejs/mask 3.15.12, flowbite 2.4.1 and flowbite-datepicker 2.0.0 from games/static/js/ instead of jsdelivr, so pages (and browser tests) work without network access. Adds the StaticScript primitive for vendored UMD bundles, which cannot be loaded as ES modules. https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK --- common/components/__init__.py | 2 + common/components/primitives.py | 6 + common/layout.py | 9 +- games/static/js/alpine-mask.min.js | 1 + games/static/js/alpine.min.js | 5 + games/static/js/datepicker.umd.js | 2784 ++++++++++++++++++++++++++++ games/static/js/flowbite.min.js | 2 + games/views/general.py | 9 +- 8 files changed, 2810 insertions(+), 8 deletions(-) create mode 100644 games/static/js/alpine-mask.min.js create mode 100644 games/static/js/alpine.min.js create mode 100644 games/static/js/datepicker.umd.js create mode 100644 games/static/js/flowbite.min.js diff --git a/common/components/__init__.py b/common/components/__init__.py index a7cd5c9..6347e9c 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -59,6 +59,7 @@ from common.components.primitives import ( SearchField, SimpleTable, Span, + StaticScript, TableHeader, TableRow, TableTd, @@ -112,6 +113,7 @@ __all__ = [ "searchselect_selected", "SimpleTable", "Span", + "StaticScript", "Label", "TableHeader", "TableRow", diff --git a/common/components/primitives.py b/common/components/primitives.py index 8fb683e..0b41daf 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -554,6 +554,12 @@ def ExternalScript(url: str) -> SafeText: return mark_safe(f'') +def StaticScript(filename: str) -> SafeText: + """A plain (classic, non-module) `') + + def YearPicker( year: int | None = None, available_years: tuple[int, ...] = (), diff --git a/common/layout.py b/common/layout.py index a143335..1886491 100644 --- a/common/layout.py +++ b/common/layout.py @@ -309,9 +309,12 @@ def Page( f' \n' f" {django_htmx_script(nonce=None)}\n" f' \n' - ' \n' - ' \n' - ' \n' + # Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) — + # served locally so pages work offline (and in browser tests). The mask + # plugin must load before Alpine core; both stay deferred. + f' \n' + f' \n' + f' \n' f" {_THEME_FOUC_SCRIPT}\n" " \n" ) diff --git a/games/static/js/alpine-mask.min.js b/games/static/js/alpine-mask.min.js new file mode 100644 index 0000000..bc35b09 --- /dev/null +++ b/games/static/js/alpine-mask.min.js @@ -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{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)});})(); diff --git a/games/static/js/alpine.min.js b/games/static/js/alpine.min.js new file mode 100644 index 0000000..ab371ef --- /dev/null +++ b/games/static/js/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + +
- {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 - )} + { + 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, + ) + }
""" 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): @@ -52,9 +60,9 @@ def test_search_select_backspace_clears_single_select(live_server, page): }""") search_input = page.locator("input[data-search-select-search]") - + assert search_input.input_value() == "Game A" - + hidden_input = page.locator('input[name="games"]') assert hidden_input.first.get_attribute("value") == "7" @@ -85,7 +93,7 @@ def test_search_select_typing_replaces_single_select(live_server, page): page.goto(live_server.url + "/test-search-select/") search_input = page.locator("input[data-search-select-search]") - + search_input.focus() assert search_input.input_value() == "" diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py new file mode 100644 index 0000000..4e95e88 --- /dev/null +++ b/e2e/test_widgets_e2e.py @@ -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() diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js index 248d38a..05a9253 100644 --- a/games/static/js/add_purchase.js +++ b/games/static/js/add_purchase.js @@ -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(); + }); }); diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index 9281509..e83fa99 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -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,27 +412,25 @@ // ── Init on page load ─────────────────────────────────────────────────── - // ── Inject search inputs into filter forms ── - function injectSearchInputs() { - document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) { - if (form.querySelector('[name="filter-search"]')) return; // already added - var input = document.createElement("input"); - input.type = "text"; - input.name = "filter-search"; - input.placeholder = "Search\u2026"; - input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand"; - // Pre-fill from existing filter JSON - var hidden = form.querySelector('[name="filter"]'); - if (hidden && hidden.parentNode) { - try { - var existing = JSON.parse(hidden.value || "{}"); - if (existing.search && existing.search.value) { - input.value = existing.search.value; - } - } catch (e) {} - hidden.parentNode.insertBefore(input, hidden.nextSibling); - } - }); + // ── 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"; + input.name = "filter-search"; + input.placeholder = "Search\u2026"; + input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand"; + // Pre-fill from existing filter JSON + var hidden = form.querySelector('[name="filter"]'); + if (hidden && hidden.parentNode) { + try { + var existing = JSON.parse(hidden.value || "{}"); + if (existing.search && existing.search.value) { + input.value = existing.search.value; + } + } catch (e) {} + hidden.parentNode.insertBefore(input, hidden.nextSibling); + } } /** @@ -438,25 +438,25 @@ */ function setupDeselectableRadios() { document.querySelectorAll('input[type="radio"]').forEach(function (radio) { - radio.addEventListener('click', function (e) { - if (this.wasChecked) { - this.checked = false; - this.wasChecked = false; - this.dispatchEvent(new Event('change', { bubbles: true })); - } else { - var name = this.getAttribute('name'); - if (name) { - document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) { - r.wasChecked = false; - }); - } - this.wasChecked = true; + radio.addEventListener('click', function (e) { + if (this.wasChecked) { + this.checked = false; + this.wasChecked = false; + this.dispatchEvent(new Event('change', { bubbles: true })); + } else { + var name = this.getAttribute('name'); + if (name) { + document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) { + r.wasChecked = false; + }); } - }); - if (radio.checked) { - radio.wasChecked = true; + this.wasChecked = true; } }); + if (radio.checked) { + radio.wasChecked = true; + } + }); } /** @@ -464,14 +464,14 @@ */ function setupStringFilters() { document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) { - radio.addEventListener('change', function () { - window.toggleStringFilterInput(this); - }); + radio.addEventListener('change', function () { + window.toggleStringFilterInput(this); + }); }); } - document.addEventListener("DOMContentLoaded", function () { - injectSearchInputs(); + onSwap('[id^="filter-bar-form"]', function (form) { + injectSearchInput(form); setupDeselectableRadios(); setupStringFilters(); loadPresets(); diff --git a/games/static/js/range_slider.js b/games/static/js/range_slider.js index 607444e..03dce46 100644 --- a/games/static/js/range_slider.js +++ b/games/static/js/range_slider.js @@ -8,229 +8,223 @@ * 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"); + var maxHandle = slider.querySelector(".range-handle-max"); + if (!minHandle || !maxHandle) return; - var mode = slider.getAttribute("data-mode") || "range"; - var trackFill = slider.querySelector(".range-track-fill"); - var minHandle = slider.querySelector(".range-handle-min"); - var maxHandle = slider.querySelector(".range-handle-max"); - if (!minHandle || !maxHandle) return; + var minTarget = document.getElementById( + minHandle.getAttribute("data-target") + ); + var maxTarget = document.getElementById( + maxHandle.getAttribute("data-target") + ); + var dataMin = parseInt(slider.getAttribute("data-min"), 10); + var dataMax = parseInt(slider.getAttribute("data-max"), 10); + var step = parseInt(slider.getAttribute("data-step"), 10) || 1; - var minTarget = document.getElementById( - minHandle.getAttribute("data-target") - ); - var maxTarget = document.getElementById( - maxHandle.getAttribute("data-target") - ); - var dataMin = parseInt(slider.getAttribute("data-min"), 10); - var dataMax = parseInt(slider.getAttribute("data-max"), 10); - var step = parseInt(slider.getAttribute("data-step"), 10) || 1; + // ── Helpers ── - // ── Helpers ── + function valueToPercent(value) { + return ((value - dataMin) / (dataMax - dataMin)) * 100; + } + function percentToValue(percent) { + var raw = dataMin + (percent / 100) * (dataMax - dataMin); + return Math.round(raw / step) * step; + } + function clamp(value, lo, hi) { + return Math.max(lo, Math.min(hi, value)); + } - function valueToPercent(value) { - return ((value - dataMin) / (dataMax - dataMin)) * 100; - } - function percentToValue(percent) { - var raw = dataMin + (percent / 100) * (dataMax - dataMin); - return Math.round(raw / step) * step; - } - function clamp(value, lo, hi) { - return Math.max(lo, Math.min(hi, value)); - } + function getTargetValue(target, defaultVal) { + if (!target || target.value === "") return defaultVal; + var parsed = parseInt(target.value, 10); + return isNaN(parsed) ? defaultVal : parsed; + } + function setTargetValue(target, value) { + if (target) target.value = value; + } - function getTargetValue(target, defaultVal) { - if (!target || target.value === "") return defaultVal; - var parsed = parseInt(target.value, 10); - return isNaN(parsed) ? defaultVal : parsed; - } - function setTargetValue(target, value) { - if (target) target.value = value; - } + // ── Track fill positioning ── - // ── Track fill positioning ── - - function updateTrackFill() { - if (!trackFill) return; - var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax); - var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax); - if (mode === "point") { - trackFill.style.left = "0%"; - trackFill.style.width = valueToPercent(maxVal) + "%"; - } else { - var leftPct = valueToPercent(minVal); - var rightPct = valueToPercent(maxVal); - if (leftPct > rightPct) { - var tmp = leftPct; - leftPct = rightPct; - rightPct = tmp; - } - var widthPct = rightPct - leftPct; - trackFill.style.left = leftPct + "%"; - trackFill.style.width = widthPct + "%"; + function updateTrackFill() { + if (!trackFill) return; + var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax); + var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax); + if (mode === "point") { + trackFill.style.left = "0%"; + trackFill.style.width = valueToPercent(maxVal) + "%"; + } else { + var leftPct = valueToPercent(minVal); + var rightPct = valueToPercent(maxVal); + if (leftPct > rightPct) { + var tmp = leftPct; + leftPct = rightPct; + rightPct = tmp; } + var widthPct = rightPct - leftPct; + trackFill.style.left = leftPct + "%"; + trackFill.style.width = widthPct + "%"; } + } - function updateHandles() { - var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax); - var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax); - minHandle.style.left = valueToPercent(minVal) + "%"; - maxHandle.style.left = valueToPercent(maxVal) + "%"; - updateTrackFill(); - } + function updateHandles() { + var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax); + var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax); + minHandle.style.left = valueToPercent(minVal) + "%"; + maxHandle.style.left = valueToPercent(maxVal) + "%"; + updateTrackFill(); + } - // ── Dragging ── + // ── Dragging ── - function makeDraggable(handle, isMin) { - handle.addEventListener("mousedown", function (e) { - e.preventDefault(); - var rect = slider.getBoundingClientRect(); + function makeDraggable(handle, isMin) { + handle.addEventListener("mousedown", function (e) { + e.preventDefault(); + var rect = slider.getBoundingClientRect(); - function onMove(ev) { - var pct = ((ev.clientX - rect.left) / rect.width) * 100; - var value = percentToValue(clamp(pct, 0, 100)); + function onMove(ev) { + var pct = ((ev.clientX - rect.left) / rect.width) * 100; + var value = percentToValue(clamp(pct, 0, 100)); - if (mode === "point") { - setTargetValue(minTarget, value); - setTargetValue(maxTarget, value); - if (minTarget) - minTarget.dispatchEvent( - new Event("input", { bubbles: true }) - ); - if (maxTarget) - maxTarget.dispatchEvent( - new Event("input", { bubbles: true }) - ); - } else if (isMin) { - setTargetValue( - minTarget, - clamp(value, dataMin, getTargetValue(maxTarget, dataMax)) + if (mode === "point") { + setTargetValue(minTarget, value); + setTargetValue(maxTarget, value); + if (minTarget) + minTarget.dispatchEvent( + new Event("input", { bubbles: true }) ); - if (minTarget) - minTarget.dispatchEvent( - new Event("input", { bubbles: true }) - ); - } else { - setTargetValue( - maxTarget, - clamp(value, getTargetValue(minTarget, dataMin), dataMax) + if (maxTarget) + maxTarget.dispatchEvent( + new Event("input", { bubbles: true }) + ); + } else if (isMin) { + setTargetValue( + minTarget, + clamp(value, dataMin, getTargetValue(maxTarget, dataMax)) + ); + if (minTarget) + minTarget.dispatchEvent( + new Event("input", { bubbles: true }) ); - if (maxTarget) - maxTarget.dispatchEvent( - new Event("input", { bubbles: true }) - ); - } - updateHandles(); - } - - function onUp() { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - } - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - onMove(e); - }); - } - - makeDraggable(minHandle, true); - makeDraggable(maxHandle, false); - - // ── Sync from number inputs back to handles ── - - function syncFromInputs(e) { - if (mode === "point") { - var src = (e && e.target) || minTarget || maxTarget; - var val = src ? src.value : ""; - setTargetValue(minTarget, val); - setTargetValue(maxTarget, val); - } else if (e && e.target) { - var minVal = getTargetValue(minTarget, dataMin); - var maxVal = getTargetValue(maxTarget, dataMax); - if (e.target === minTarget) { - if (minVal > maxVal) { - setTargetValue(maxTarget, minVal); - } - } else if (e.target === maxTarget) { - if (maxVal < minVal) { - setTargetValue(minTarget, maxVal); - } - } - } - updateHandles(); - } - - function enforceStrictBounds(e) { - if (e && e.target) { - var val = parseInt(e.target.value, 10); - if (!isNaN(val)) { - var clamped = clamp(val, dataMin, dataMax); - if (clamped !== val) { - setTargetValue(e.target, clamped); - e.target.dispatchEvent(new Event("input", { bubbles: true })); - } - } - } - } - - if (minTarget) { - minTarget.addEventListener("input", syncFromInputs); - minTarget.addEventListener("change", enforceStrictBounds); - } - if (maxTarget) { - maxTarget.addEventListener("input", syncFromInputs); - maxTarget.addEventListener("change", enforceStrictBounds); - } - - // ── Mode toggle ── - - var block = slider.closest(".range-slider-block"); - var toggleButton = - block && block.querySelector(".range-mode-toggle"); - if (toggleButton) { - toggleButton.addEventListener("click", function () { - var newMode = mode === "range" ? "point" : "range"; - slider.setAttribute("data-mode", newMode); - - // Swap toggle icons - var iconRange = toggleButton.querySelector( - ".range-mode-icon-range" - ); - var iconPoint = toggleButton.querySelector( - ".range-mode-icon-point" - ); - if (iconRange) iconRange.classList.toggle("hidden"); - if (iconPoint) iconPoint.classList.toggle("hidden"); - - var dashSpan = block && block.querySelector(".range-dash"); - if (newMode === "point") { - minHandle.style.display = "none"; - setTargetValue(minTarget, maxTarget ? maxTarget.value : ""); - if (minTarget) minTarget.classList.add("hidden"); - if (dashSpan) dashSpan.classList.add("hidden"); } else { - minHandle.style.display = ""; - if (minTarget) minTarget.classList.remove("hidden"); - if (dashSpan) dashSpan.classList.remove("hidden"); + setTargetValue( + maxTarget, + clamp(value, getTargetValue(minTarget, dataMin), dataMax) + ); + if (maxTarget) + maxTarget.dispatchEvent( + new Event("input", { bubbles: true }) + ); } - mode = newMode; updateHandles(); - }); - } + } - // ── Initial position ── + function onUp() { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + onMove(e); + }); + } + + makeDraggable(minHandle, true); + makeDraggable(maxHandle, false); + + // ── Sync from number inputs back to handles ── + + function syncFromInputs(e) { + if (mode === "point") { + var src = (e && e.target) || minTarget || maxTarget; + var val = src ? src.value : ""; + setTargetValue(minTarget, val); + setTargetValue(maxTarget, val); + } else if (e && e.target) { + var minVal = getTargetValue(minTarget, dataMin); + var maxVal = getTargetValue(maxTarget, dataMax); + if (e.target === minTarget) { + if (minVal > maxVal) { + setTargetValue(maxTarget, minVal); + } + } else if (e.target === maxTarget) { + if (maxVal < minVal) { + setTargetValue(minTarget, maxVal); + } + } + } updateHandles(); - }); + } + + function enforceStrictBounds(e) { + if (e && e.target) { + var val = parseInt(e.target.value, 10); + if (!isNaN(val)) { + var clamped = clamp(val, dataMin, dataMax); + if (clamped !== val) { + setTargetValue(e.target, clamped); + e.target.dispatchEvent(new Event("input", { bubbles: true })); + } + } + } + } + + if (minTarget) { + minTarget.addEventListener("input", syncFromInputs); + minTarget.addEventListener("change", enforceStrictBounds); + } + if (maxTarget) { + maxTarget.addEventListener("input", syncFromInputs); + maxTarget.addEventListener("change", enforceStrictBounds); + } + + // ── Mode toggle ── + + var block = slider.closest(".range-slider-block"); + var toggleButton = + block && block.querySelector(".range-mode-toggle"); + if (toggleButton) { + toggleButton.addEventListener("click", function () { + var newMode = mode === "range" ? "point" : "range"; + slider.setAttribute("data-mode", newMode); + + // Swap toggle icons + var iconRange = toggleButton.querySelector( + ".range-mode-icon-range" + ); + var iconPoint = toggleButton.querySelector( + ".range-mode-icon-point" + ); + if (iconRange) iconRange.classList.toggle("hidden"); + if (iconPoint) iconPoint.classList.toggle("hidden"); + + var dashSpan = block && block.querySelector(".range-dash"); + if (newMode === "point") { + minHandle.style.display = "none"; + setTargetValue(minTarget, maxTarget ? maxTarget.value : ""); + if (minTarget) minTarget.classList.add("hidden"); + if (dashSpan) dashSpan.classList.add("hidden"); + } else { + minHandle.style.display = ""; + if (minTarget) minTarget.classList.remove("hidden"); + if (dashSpan) dashSpan.classList.remove("hidden"); + } + mode = newMode; + updateHandles(); + }); + } + + // ── Initial position ── + updateHandles(); } - document.addEventListener("DOMContentLoaded", initAll); - document.addEventListener("htmx:afterSwap", initAll); - window.initRangeSliders = initAll; -})(); \ No newline at end of file + onSwap(".range-slider", initializeSlider); +})(); diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index 40a2273..62f59a0 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -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