diff --git a/common/components/filters.py b/common/components/filters.py index 056ec49..b09a6b9 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -52,11 +52,11 @@ _FILTER_RADIO_CLASS = ( _FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" -# range_slider.js wires RangeSlider; filter_bar.js wires the bar chrome -# (Apply/Clear, presets, search injection). Widget media (search_select.js, +# range_slider.js wires RangeSlider; ts/filter_bar.ts wires the bar chrome +# (Apply/Clear, presets, search injection). Widget media (dist/search_select.js, # date_range_picker.js) bubbles up from the contained FilterSelect / picker. _RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",)) -_FILTER_BAR_MEDIA = Media(js=("filter_bar.js",)) +_FILTER_BAR_MEDIA = Media(js=("dist/filter_bar.js",)) def _filter_parse(filter_json: str) -> dict: diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py index ea894c8..b6e789c 100644 --- a/e2e/test_boolean_filter_e2e.py +++ b/e2e/test_boolean_filter_e2e.py @@ -24,7 +24,7 @@ def _bar_page(filter_json: str = "") -> str: - + {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py index dad2799..9abe8a0 100644 --- a/e2e/test_date_filter_e2e.py +++ b/e2e/test_date_filter_e2e.py @@ -32,7 +32,7 @@ def _bar_page(filter_json: str = "") -> str: - + {PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py index 3fcdc46..9aa78c4 100644 --- a/e2e/test_date_range_picker_e2e.py +++ b/e2e/test_date_range_picker_e2e.py @@ -32,7 +32,7 @@ def _bar_page(filter_json: str = "") -> str: - + {PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py index 204ce64..b1bfc74 100644 --- a/e2e/test_range_slider_e2e.py +++ b/e2e/test_range_slider_e2e.py @@ -16,7 +16,7 @@ def _bar_page(filter_json: str = "") -> str: - + {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py index 2f9b3a5..24fd7ac 100644 --- a/e2e/test_string_filter_e2e.py +++ b/e2e/test_string_filter_e2e.py @@ -19,7 +19,7 @@ def _bar_page(filter_json: str = "") -> str: - + {PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js deleted file mode 100644 index e83fa99..0000000 --- a/games/static/js/filter_bar.js +++ /dev/null @@ -1,479 +0,0 @@ -/** - * Filter bar — vanilla JavaScript implementation. - * - * 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"; - - /** Build a criterion object from a value and optional second value. */ - function criterion(value, value2, modifier) { - var c = { value: value, modifier: modifier }; - if (value2 !== null && value2 !== undefined && value2 !== "") { - c.value2 = value2; - } - return c; - } - - /** Read a value, or "" if not found. */ - function numberValue(form, name) { - var el = form.querySelector('[name="' + name + '"]'); - if (!el || el.value === "") return ""; - var val = parseFloat(el.value); - return isNaN(val) ? "" : val; - } - - /** Read a raw value as string, or "" if not found. */ - function stringValue(form, name) { - var el = form.querySelector('[name="' + name + '"]'); - return el ? el.value : ""; - } - - /** - * Derive a range criterion ({value, value2?, modifier}) from a (min, max) - * pair, or null if both bounds are empty. Shared by the numeric-range and - * date-range serializers. - */ - function buildRangeCriterion(vMin, vMax) { - if (vMin !== "" && vMax !== "") return criterion(vMin, vMax, "BETWEEN"); - if (vMin !== "") return criterion(vMin, null, "GREATER_THAN"); - if (vMax !== "") return criterion(vMax, null, "LESS_THAN"); - return null; - } - - /** Read all checked checkboxes with a given name, returning an array of ints. */ - function checkedValues(form, name) { - var els = form.querySelectorAll('[name="' + name + '"]:checked'); - var ids = []; - els.forEach(function (el) { - var v = parseInt(el.value, 10); - if (!isNaN(v)) ids.push(v); - }); - return ids; - } - - /** - * Build the filter JSON object from form field values. - * Returns a plain object ready for JSON.stringify. - */ - function buildFilterJSON(form) { - var filter = {}; - - // ── Search field ── - var searchInput = form.querySelector('[name="filter-search"]'); - if (searchInput && searchInput.value.trim()) { - filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" }; - } - - // ── FilterSelect widgets (data-search-select-mode="filter") ── - // readSearchSelect serialises each into data-included/data-excluded/data-modifier. - readSearchSelect(form); - var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]'); - widgets.forEach(function (widget) { - var field = widget.getAttribute("data-name"); - var included = parseJSONAttr(widget, "data-included"); - var excluded = parseJSONAttr(widget, "data-excluded"); - // Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the - // pinned (Any)/(None) pseudo-options clears the value set and has no - // values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs - // how the include set matches. When neither is set the implicit default - // is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS. - var modifier = widget.getAttribute("data-modifier"); - var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL"; - if (IS_PRESENCE) { - filter[field] = { modifier: modifier }; - } else if (included.length > 0 || excluded.length > 0) { - // All filter pills carry {id, label}; store them as-is so the filter - // URL and saved presets are self-describing (Stash-style). - filter[field] = { - value: included.map(function (item) { return {id: item.id, label: item.label}; }), - excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }), - modifier: modifier || "INCLUDES", - }; - } - }); - - // 1. Text Fields - var textFields = [ - { name: "filter-price_currency", key: "price_currency" }, - { name: "filter-converted_currency", key: "converted_currency" }, - { name: "filter-name", key: "name" }, - { name: "filter-group", key: "group" }, - { name: "filter-playevent_note", key: "playevent_note" }, - { name: "filter-note", key: "note" } - ]; - textFields.forEach(function (tf) { - var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked'); - var modifier = modifierEl ? modifierEl.value : "EQUALS"; - - var isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL"; - if (isPresence) { - filter[tf.key] = { modifier: modifier }; - } else { - var el = form.querySelector('[name="' + tf.name + '"]'); - if (el && el.value.trim()) { - filter[tf.key] = { value: el.value.trim(), modifier: modifier }; - } - } - }); - - // 2. Boolean Fields (Radio Button Groups) - var booleanFields = [ - { name: "filter-mastered", key: "mastered" }, - { name: "filter-emulated", key: "emulated" }, - { name: "filter-active", key: "is_active" }, - { name: "filter-refunded", key: "is_refunded" }, - { name: "filter-infinite", key: "infinite" }, - { name: "filter-needs-price-update", key: "needs_price_update" }, - { name: "filter-purchase-refunded", key: "purchase_refunded" }, - { name: "filter-purchase-infinite", key: "purchase_infinite" }, - { name: "filter-session-emulated", key: "session_emulated" } - ]; - booleanFields.forEach(function (bf) { - var el = form.querySelector('[name="' + bf.name + '"]:checked'); - if (el) { - var val = el.value === "true"; - filter[bf.key] = criterion(val, null, "EQUALS"); - } - }); - - // 3. Range Fields - var rangeFields = [ - { prefix: "filter-year", key: "year_released" }, - { prefix: "filter-original-year", key: "original_year_released" }, - { prefix: "filter-session-count", key: "session_count" }, - { prefix: "filter-session-average", key: "session_average" }, - { prefix: "filter-purchase-count", key: "purchase_count" }, - { prefix: "filter-playevent-count", key: "playevent_count" }, - { prefix: "filter-duration-total-hours", key: "duration_total_hours" }, - { prefix: "filter-duration-manual-hours", key: "duration_manual_hours" }, - { prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" }, - { prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" }, - { prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" }, - { prefix: "filter-num-purchases", key: "num_purchases" }, - { prefix: "filter-price", key: "price" }, - { prefix: "filter-purchase-price-total", key: "purchase_price_total" }, - { prefix: "filter-purchase-price-any", key: "purchase_price_any" }, - { prefix: "filter-days-to-finish", key: "days_to_finish" }, - { prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true } - ]; - - rangeFields.forEach(function (rf) { - var vMin = numberValue(form, rf.prefix + "-min"); - var vMax = numberValue(form, rf.prefix + "-max"); - - if (rf.convert) { - if (vMin !== "") vMin = rf.convert(vMin); - if (vMax !== "") vMax = rf.convert(vMax); - } - - if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) { - return; // both 0 means slider at default - } - - var c = buildRangeCriterion(vMin, vMax); - if (c !== null) filter[rf.key] = c; - }); - - // 4. Date Range Fields — ISO date strings from ; no - // numeric coercion. Same modifier derivation as numeric ranges. - var dateRangeFields = [ - { prefix: "filter-date-purchased", key: "date_purchased" }, - { prefix: "filter-date-refunded", key: "date_refunded" }, - ]; - dateRangeFields.forEach(function (df) { - var vMin = stringValue(form, df.prefix + "-min"); - var vMax = stringValue(form, df.prefix + "-max"); - var c = buildRangeCriterion(vMin, vMax); - if (c !== null) filter[df.key] = c; - }); - - return filter; - } - - /** Extract the current page's base URL (without query string). */ - function baseUrl() { - return window.location.pathname; - } - - /** Safely parse a JSON attribute, returning empty array on failure. */ - function parseJSONAttr(el, attr) { - var raw = el.getAttribute(attr); - if (!raw) return []; - try { return JSON.parse(raw); } catch (e) { return []; } - } - - /** - * Called on filter bar form submit. - * Serializes filter fields, navigates to URL with filter param. - */ - window.applyFilterBar = function (event) { - event.preventDefault(); - var form = event.target; - var filter = buildFilterJSON(form); - var filterStr = JSON.stringify(filter); - var url = baseUrl(); - if (filterStr && filterStr !== "{}") { - url += "?filter=" + encodeURIComponent(filterStr); - } - window.location.href = url; - return false; - }; - - /** - * Clear all filter fields and reload the unfiltered view. - */ - window.clearFilterBar = function (formId, filterInputId) { - var form = document.getElementById(formId); - if (!form) return; - form.reset(); - window.location.href = baseUrl(); - }; - - // ── Presets ───────────────────────────────────────────────────────────── - - /** Fetch and render the preset list. */ - function loadPresets() { - var dropdown = document.getElementById("preset-dropdown"); - if (!dropdown) return; - var url = dropdown.getAttribute("data-preset-list-url"); - if (!url) return; - - var mode = "games"; - var path = window.location.pathname; - if (path.indexOf("session") !== -1) mode = "sessions"; - else if (path.indexOf("purchase") !== -1) mode = "purchases"; - else if (path.indexOf("device") !== -1) mode = "devices"; - else if (path.indexOf("platform") !== -1) mode = "platforms"; - else if (path.indexOf("playevent") !== -1) mode = "playevents"; - - var query = ""; - if (url.indexOf("mode=") === -1) { - query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode; - } - - fetch(url + query, { credentials: "same-origin" }) - .then(function (r) { - if (!r.ok) throw new Error("Failed to load presets"); - return r.text(); - }) - .then(function (html) { - dropdown.innerHTML = html; - // Re-attach delete handlers (list_presets view uses onclick attributes, - // but we also need to wire up inline handlers if they use data attributes) - setupPresetDeleteHandlers(dropdown); - }) - .catch(function (err) { - dropdown.innerHTML = - 'Presets unavailable'; - console.error(err); - }); - } - - /** Wire up click handlers for preset delete buttons. */ - function setupPresetDeleteHandlers(container) { - var deleteLinks = container.querySelectorAll('[data-delete-preset]'); - deleteLinks.forEach(function (link) { - link.addEventListener("click", function (e) { - e.preventDefault(); - var presetId = link.getAttribute("data-delete-preset"); - var deleteUrl = link.getAttribute("href"); - if (!deleteUrl) return; - if (!confirm("Delete this preset?")) return; - fetch(deleteUrl, { - method: "POST", - credentials: "same-origin", - headers: { "X-CSRFToken": getCsrfToken() }, - }) - .then(function () { - // Remove the parent
  • - var li = link.closest("li"); - if (li) li.remove(); - // If no items left, show empty message - var ul = container.querySelector("ul"); - if (ul && ul.querySelectorAll("li").length === 0) { - ul.innerHTML = - '
  • No saved presets
  • '; - } - }) - .catch(function (err) { - console.error("Delete failed:", err); - }); - }); - }); - } - - /** Enable/disable the input text box depending on selected string modifier. */ - window.toggleStringFilterInput = function (radio) { - var container = radio.closest(".flex-col"); - if (!container) return; - var textInput = container.querySelector('input[type="text"]'); - if (!textInput) return; - - // Find the currently checked radio in the container - var checkedRadio = container.querySelector('input[type="radio"]:checked'); - var val = checkedRadio ? checkedRadio.value : ""; - - if (val === "IS_NULL" || val === "NOT_NULL") { - textInput.disabled = true; - textInput.value = ""; - textInput.classList.add("opacity-50", "cursor-not-allowed"); - } else { - textInput.disabled = false; - textInput.classList.remove("opacity-50", "cursor-not-allowed"); - } - }; - - /** Show the preset name input field and the confirm button. */ - window.showPresetNameInput = function () { - var input = document.getElementById("preset-name-input"); - var saveBtn = document.getElementById("save-preset-btn"); - var confirmBtn = document.getElementById("confirm-save-preset-btn"); - if (input) input.classList.remove("hidden"); - if (saveBtn) saveBtn.classList.add("hidden"); - if (confirmBtn) confirmBtn.classList.remove("hidden"); - if (input) input.focus(); - }; - - /** Save the current filter as a named preset. */ - window.savePreset = function (formId, filterInputId, saveUrl) { - var input = document.getElementById("preset-name-input"); - var name = input ? input.value.trim() : ""; - if (!name) { - if (input) input.classList.add("border-red-500"); - return; - } - - var filterInput = document.getElementById(filterInputId); - var form = document.getElementById(formId); - var filterObj = form ? buildFilterJSON(form) : {}; - - var body = new URLSearchParams(); - body.append("name", name); - var mode = "games"; - var path = window.location.pathname; - if (path.indexOf("session") !== -1) mode = "sessions"; - else if (path.indexOf("purchase") !== -1) mode = "purchases"; - else if (path.indexOf("device") !== -1) mode = "devices"; - else if (path.indexOf("platform") !== -1) mode = "platforms"; - else if (path.indexOf("playevent") !== -1) mode = "playevents"; - body.append("mode", mode); - body.append("filter", JSON.stringify(filterObj)); - - fetch(saveUrl, { - method: "POST", - credentials: "same-origin", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-CSRFToken": getCsrfToken(), - }, - body: body.toString(), - }) - .then(function (r) { - if (!r.ok) throw new Error("Save failed"); - // Reset UI - if (input) { - input.value = ""; - input.classList.add("hidden"); - input.classList.remove("border-red-500"); - } - var saveBtn = document.getElementById("save-preset-btn"); - var confirmBtn = document.getElementById("confirm-save-preset-btn"); - if (saveBtn) saveBtn.classList.remove("hidden"); - if (confirmBtn) confirmBtn.classList.add("hidden"); - // Refresh the preset list - loadPresets(); - }) - .catch(function (err) { - console.error("Failed to save preset:", err); - }); - }; - - /** Extract CSRF token from the page. */ - function getCsrfToken() { - var cookie = document.cookie - .split("; ") - .find(function (row) { - return row.startsWith("csrftoken="); - }); - if (cookie) return cookie.split("=")[1]; - var el = document.querySelector('input[name="csrfmiddlewaretoken"]'); - return el ? el.value : ""; - } - - // ── Init on page load ─────────────────────────────────────────────────── - - // ── 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); - } - } - - /** - * Enable deselect-on-click behavior for filter radio buttons. - */ - 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; - } - }); - if (radio.checked) { - radio.wasChecked = true; - } - }); - } - - /** - * Set up event listeners for string modifier radio buttons. - */ - function setupStringFilters() { - document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) { - radio.addEventListener('change', function () { - window.toggleStringFilterInput(this); - }); - }); - } - - onSwap('[id^="filter-bar-form"]', function (form) { - injectSearchInput(form); - setupDeselectableRadios(); - setupStringFilters(); - loadPresets(); - }); -})(); diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py index 8a25492..3496b02 100644 --- a/tests/test_node_tree.py +++ b/tests/test_node_tree.py @@ -169,7 +169,7 @@ class RealComponentMediaTest(unittest.TestCase): from common.components import FilterBar media = collect_media(FilterBar()) - self.assertIn("filter_bar.js", media.js) + self.assertIn("dist/filter_bar.js", media.js) self.assertIn("dist/search_select.js", media.js) self.assertIn("range_slider.js", media.js) diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py index fdc19d7..ec72004 100644 --- a/tests/test_rendered_pages.py +++ b/tests/test_rendered_pages.py @@ -63,7 +63,7 @@ class RenderedPagesTest(TestCase): """The games list view passes no scripts= argument; the filter bar's components declare their JS and Page() collects it.""" html = self.get("games:list_games").content.decode() - self.assertIn("js/filter_bar.js", html) + self.assertIn("js/dist/filter_bar.js", html) self.assertIn("js/dist/search_select.js", html) self.assertIn("js/range_slider.js", html) diff --git a/ts/filter_bar.ts b/ts/filter_bar.ts new file mode 100644 index 0000000..3128fa4 --- /dev/null +++ b/ts/filter_bar.ts @@ -0,0 +1,503 @@ +/** + * Filter bar — vanilla TypeScript implementation. + * + * Handles form submission, preset loading/saving, and preset list rendering. + * No HTMX — plain fetch() and window.location for all interactions. The + * applyFilterBar / clearFilterBar / toggleStringFilterInput / showPresetNameInput + * / savePreset entry points are assigned to window so the server-rendered inline + * on* handlers (see common/components/filters.py) can reach them. + */ +import { onSwap } from "./utils.js"; + +interface Criterion { + value: unknown; + modifier: string; + value2?: unknown; +} + +// A filter pill as serialised by readSearchSelect onto data-included/excluded. +interface PillEntry { + id: string; + label: string; +} + +// Deselect-on-click radios stash their last-checked state on the element. +interface DeselectableRadio extends HTMLInputElement { + wasChecked?: boolean; +} + +interface RangeField { + prefix: string; + key: string; + ignoreZeroZero?: boolean; + convert?: (value: number) => number; +} + +(() => { + "use strict"; + + /** Build a criterion object from a value and optional second value. */ + function criterion(value: unknown, value2: unknown, modifier: string): Criterion { + const result: Criterion = { value, modifier }; + if (value2 !== null && value2 !== undefined && value2 !== "") { + result.value2 = value2; + } + return result; + } + + /** Read an value, or "" if not found. */ + function numberValue(form: HTMLElement, name: string): number | "" { + const element = form.querySelector(`[name="${name}"]`); + if (!element || element.value === "") return ""; + const value = parseFloat(element.value); + return isNaN(value) ? "" : value; + } + + /** Read a raw value as string, or "" if not found. */ + function stringValue(form: HTMLElement, name: string): string { + const element = form.querySelector(`[name="${name}"]`); + return element ? element.value : ""; + } + + /** + * Derive a range criterion ({value, value2?, modifier}) from a (min, max) + * pair, or null if both bounds are empty. Shared by the numeric-range and + * date-range serializers. + */ + function buildRangeCriterion( + valueMin: number | string, + valueMax: number | string + ): Criterion | null { + if (valueMin !== "" && valueMax !== "") return criterion(valueMin, valueMax, "BETWEEN"); + if (valueMin !== "") return criterion(valueMin, null, "GREATER_THAN"); + if (valueMax !== "") return criterion(valueMax, null, "LESS_THAN"); + return null; + } + + /** + * Build the filter JSON object from form field values. + * Returns a plain object ready for JSON.stringify. + */ + function buildFilterJSON(form: HTMLElement): Record { + const filter: Record = {}; + + // ── Search field ── + const searchInput = form.querySelector('[name="filter-search"]'); + if (searchInput && searchInput.value.trim()) { + filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" }; + } + + // ── FilterSelect widgets (data-search-select-mode="filter") ── + // readSearchSelect serialises each into data-included/data-excluded/data-modifier. + window.readSearchSelect(form); + const widgets = form.querySelectorAll( + '[data-search-select][data-search-select-mode="filter"]' + ); + widgets.forEach((widget) => { + const field = widget.getAttribute("data-name"); + if (!field) return; + const included = parseJSONAttr(widget, "data-included"); + const excluded = parseJSONAttr(widget, "data-excluded"); + // Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the + // pinned (Any)/(None) pseudo-options clears the value set and has no + // values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs + // how the include set matches. When neither is set the implicit default + // is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS. + const modifier = widget.getAttribute("data-modifier"); + const isPresence = modifier === "NOT_NULL" || modifier === "IS_NULL"; + if (isPresence) { + filter[field] = { modifier }; + } else if (included.length > 0 || excluded.length > 0) { + // All filter pills carry {id, label}; store them as-is so the filter + // URL and saved presets are self-describing (Stash-style). + filter[field] = { + value: included.map((item) => ({ id: item.id, label: item.label })), + excludes: excluded.map((item) => ({ id: item.id, label: item.label })), + modifier: modifier || "INCLUDES", + }; + } + }); + + // 1. Text Fields + const textFields = [ + { name: "filter-price_currency", key: "price_currency" }, + { name: "filter-converted_currency", key: "converted_currency" }, + { name: "filter-name", key: "name" }, + { name: "filter-group", key: "group" }, + { name: "filter-playevent_note", key: "playevent_note" }, + { name: "filter-note", key: "note" }, + ]; + textFields.forEach((textField) => { + const modifierElement = form.querySelector( + `[name="${textField.name}-modifier"]:checked` + ); + const modifier = modifierElement ? modifierElement.value : "EQUALS"; + + const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL"; + if (isPresence) { + filter[textField.key] = { modifier }; + } else { + const element = form.querySelector(`[name="${textField.name}"]`); + if (element && element.value.trim()) { + filter[textField.key] = { value: element.value.trim(), modifier }; + } + } + }); + + // 2. Boolean Fields (Radio Button Groups) + const booleanFields = [ + { name: "filter-mastered", key: "mastered" }, + { name: "filter-emulated", key: "emulated" }, + { name: "filter-active", key: "is_active" }, + { name: "filter-refunded", key: "is_refunded" }, + { name: "filter-infinite", key: "infinite" }, + { name: "filter-needs-price-update", key: "needs_price_update" }, + { name: "filter-purchase-refunded", key: "purchase_refunded" }, + { name: "filter-purchase-infinite", key: "purchase_infinite" }, + { name: "filter-session-emulated", key: "session_emulated" }, + ]; + booleanFields.forEach((booleanField) => { + const element = form.querySelector( + `[name="${booleanField.name}"]:checked` + ); + if (element) { + const value = element.value === "true"; + filter[booleanField.key] = criterion(value, null, "EQUALS"); + } + }); + + // 3. Range Fields + const rangeFields: RangeField[] = [ + { prefix: "filter-year", key: "year_released" }, + { prefix: "filter-original-year", key: "original_year_released" }, + { prefix: "filter-session-count", key: "session_count" }, + { prefix: "filter-session-average", key: "session_average" }, + { prefix: "filter-purchase-count", key: "purchase_count" }, + { prefix: "filter-playevent-count", key: "playevent_count" }, + { prefix: "filter-duration-total-hours", key: "duration_total_hours" }, + { prefix: "filter-duration-manual-hours", key: "duration_manual_hours" }, + { prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" }, + { prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" }, + { prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" }, + { prefix: "filter-num-purchases", key: "num_purchases" }, + { prefix: "filter-price", key: "price" }, + { prefix: "filter-purchase-price-total", key: "purchase_price_total" }, + { prefix: "filter-purchase-price-any", key: "purchase_price_any" }, + { prefix: "filter-days-to-finish", key: "days_to_finish" }, + { prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true }, + ]; + + rangeFields.forEach((rangeField) => { + let valueMin = numberValue(form, rangeField.prefix + "-min"); + let valueMax = numberValue(form, rangeField.prefix + "-max"); + + if (rangeField.convert) { + if (valueMin !== "") valueMin = rangeField.convert(valueMin); + if (valueMax !== "") valueMax = rangeField.convert(valueMax); + } + + if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) { + return; // both 0 means slider at default + } + + const result = buildRangeCriterion(valueMin, valueMax); + if (result !== null) filter[rangeField.key] = result; + }); + + // 4. Date Range Fields — ISO date strings from ; no + // numeric coercion. Same modifier derivation as numeric ranges. + const dateRangeFields = [ + { prefix: "filter-date-purchased", key: "date_purchased" }, + { prefix: "filter-date-refunded", key: "date_refunded" }, + ]; + dateRangeFields.forEach((dateField) => { + const valueMin = stringValue(form, dateField.prefix + "-min"); + const valueMax = stringValue(form, dateField.prefix + "-max"); + const result = buildRangeCriterion(valueMin, valueMax); + if (result !== null) filter[dateField.key] = result; + }); + + return filter; + } + + /** Extract the current page's base URL (without query string). */ + function baseUrl(): string { + return window.location.pathname; + } + + /** Safely parse a JSON attribute, returning empty array on failure. */ + function parseJSONAttr(element: Element, attr: string): T[] { + const raw = element.getAttribute(attr); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch { + return []; + } + } + + /** Map the current path to a preset mode. */ + function presetMode(): string { + const path = window.location.pathname; + if (path.indexOf("session") !== -1) return "sessions"; + if (path.indexOf("purchase") !== -1) return "purchases"; + if (path.indexOf("device") !== -1) return "devices"; + if (path.indexOf("platform") !== -1) return "platforms"; + if (path.indexOf("playevent") !== -1) return "playevents"; + return "games"; + } + + /** + * Called on filter bar form submit. + * Serializes filter fields, navigates to URL with filter param. + */ + window.applyFilterBar = (event: Event): boolean => { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const filter = buildFilterJSON(form); + const filterString = JSON.stringify(filter); + let url = baseUrl(); + if (filterString && filterString !== "{}") { + url += "?filter=" + encodeURIComponent(filterString); + } + window.location.href = url; + return false; + }; + + /** + * Clear all filter fields and reload the unfiltered view. + */ + window.clearFilterBar = (formId: string, _filterInputId: string): void => { + const form = document.getElementById(formId) as HTMLFormElement | null; + if (!form) return; + form.reset(); + window.location.href = baseUrl(); + }; + + // ── Presets ───────────────────────────────────────────────────────────── + + /** Fetch and render the preset list. */ + function loadPresets(): void { + const dropdown = document.getElementById("preset-dropdown"); + if (!dropdown) return; + const url = dropdown.getAttribute("data-preset-list-url"); + if (!url) return; + + const mode = presetMode(); + let query = ""; + if (url.indexOf("mode=") === -1) { + query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode; + } + + fetch(url + query, { credentials: "same-origin" }) + .then((response) => { + if (!response.ok) throw new Error("Failed to load presets"); + return response.text(); + }) + .then((html) => { + dropdown.innerHTML = html; + // Re-attach delete handlers (list_presets view uses onclick attributes, + // but we also need to wire up inline handlers if they use data attributes) + setupPresetDeleteHandlers(dropdown); + }) + .catch((error) => { + dropdown.innerHTML = + 'Presets unavailable'; + console.error(error); + }); + } + + /** Wire up click handlers for preset delete buttons. */ + function setupPresetDeleteHandlers(container: HTMLElement): void { + const deleteLinks = container.querySelectorAll("[data-delete-preset]"); + deleteLinks.forEach((link) => { + link.addEventListener("click", (event) => { + event.preventDefault(); + const deleteUrl = link.getAttribute("href"); + if (!deleteUrl) return; + if (!confirm("Delete this preset?")) return; + fetch(deleteUrl, { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRFToken": getCsrfToken() }, + }) + .then(() => { + // Remove the parent
  • + const listItem = link.closest("li"); + if (listItem) listItem.remove(); + // If no items left, show empty message + const list = container.querySelector("ul"); + if (list && list.querySelectorAll("li").length === 0) { + list.innerHTML = + '
  • No saved presets
  • '; + } + }) + .catch((error) => { + console.error("Delete failed:", error); + }); + }); + }); + } + + /** Enable/disable the input text box depending on selected string modifier. */ + window.toggleStringFilterInput = (radio: HTMLInputElement): void => { + const container = radio.closest(".flex-col"); + if (!container) return; + const textInput = container.querySelector('input[type="text"]'); + if (!textInput) return; + + // Find the currently checked radio in the container + const checkedRadio = container.querySelector('input[type="radio"]:checked'); + const value = checkedRadio ? checkedRadio.value : ""; + + if (value === "IS_NULL" || value === "NOT_NULL") { + textInput.disabled = true; + textInput.value = ""; + textInput.classList.add("opacity-50", "cursor-not-allowed"); + } else { + textInput.disabled = false; + textInput.classList.remove("opacity-50", "cursor-not-allowed"); + } + }; + + /** Show the preset name input field and the confirm button. */ + window.showPresetNameInput = (): void => { + const input = document.getElementById("preset-name-input"); + const saveButton = document.getElementById("save-preset-btn"); + const confirmButton = document.getElementById("confirm-save-preset-btn"); + if (input) input.classList.remove("hidden"); + if (saveButton) saveButton.classList.add("hidden"); + if (confirmButton) confirmButton.classList.remove("hidden"); + if (input) input.focus(); + }; + + /** Save the current filter as a named preset. */ + window.savePreset = (formId: string, _filterInputId: string, saveUrl: string): void => { + const input = document.getElementById("preset-name-input") as HTMLInputElement | null; + const name = input ? input.value.trim() : ""; + if (!name) { + if (input) input.classList.add("border-red-500"); + return; + } + + const form = document.getElementById(formId); + const filterObject = form ? buildFilterJSON(form) : {}; + + const body = new URLSearchParams(); + body.append("name", name); + body.append("mode", presetMode()); + body.append("filter", JSON.stringify(filterObject)); + + fetch(saveUrl, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRFToken": getCsrfToken(), + }, + body: body.toString(), + }) + .then((response) => { + if (!response.ok) throw new Error("Save failed"); + // Reset UI + if (input) { + input.value = ""; + input.classList.add("hidden"); + input.classList.remove("border-red-500"); + } + const saveButton = document.getElementById("save-preset-btn"); + const confirmButton = document.getElementById("confirm-save-preset-btn"); + if (saveButton) saveButton.classList.remove("hidden"); + if (confirmButton) confirmButton.classList.add("hidden"); + // Refresh the preset list + loadPresets(); + }) + .catch((error) => { + console.error("Failed to save preset:", error); + }); + }; + + /** Extract CSRF token from the page. */ + function getCsrfToken(): string { + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith("csrftoken=")); + if (cookie) return cookie.split("=")[1]; + const element = document.querySelector('input[name="csrfmiddlewaretoken"]'); + return element ? element.value : ""; + } + + // ── Init on page load ─────────────────────────────────────────────────── + + // ── Inject the search input into a filter form ── + function injectSearchInput(form: HTMLElement): void { + if (form.querySelector('[name="filter-search"]')) return; // already added + const input = document.createElement("input"); + input.type = "text"; + input.name = "filter-search"; + input.placeholder = "Search…"; + 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 + const hidden = form.querySelector('[name="filter"]'); + if (hidden && hidden.parentNode) { + try { + const existing = JSON.parse(hidden.value || "{}"); + if (existing.search && existing.search.value) { + input.value = existing.search.value; + } + } catch { + // ignore malformed existing filter JSON + } + hidden.parentNode.insertBefore(input, hidden.nextSibling); + } + } + + /** + * Enable deselect-on-click behavior for filter radio buttons. + */ + function setupDeselectableRadios(): void { + document.querySelectorAll('input[type="radio"]').forEach((radio) => { + radio.addEventListener("click", function (this: DeselectableRadio) { + if (this.wasChecked) { + this.checked = false; + this.wasChecked = false; + this.dispatchEvent(new Event("change", { bubbles: true })); + } else { + const name = this.getAttribute("name"); + if (name) { + document + .querySelectorAll(`input[type="radio"][name="${name}"]`) + .forEach((other) => { + other.wasChecked = false; + }); + } + this.wasChecked = true; + } + }); + if (radio.checked) { + radio.wasChecked = true; + } + }); + } + + /** + * Set up event listeners for string modifier radio buttons. + */ + function setupStringFilters(): void { + document + .querySelectorAll("input[data-string-modifier-radio]") + .forEach((radio) => { + radio.addEventListener("change", function (this: HTMLInputElement) { + window.toggleStringFilterInput(this); + }); + }); + } + + onSwap('[id^="filter-bar-form"]', (form) => { + injectSearchInput(form as HTMLElement); + setupDeselectableRadios(); + setupStringFilters(); + loadPresets(); + }); +})(); diff --git a/ts/globals.d.ts b/ts/globals.d.ts index f600b0b..b8edd32 100644 --- a/ts/globals.d.ts +++ b/ts/globals.d.ts @@ -4,5 +4,10 @@ declare global { interface Window { fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise; readSearchSelect(form: HTMLElement): void; + applyFilterBar(event: Event): boolean; + clearFilterBar(formId: string, filterInputId: string): void; + toggleStringFilterInput(radio: HTMLInputElement): void; + showPresetNameInput(): void; + savePreset(formId: string, filterInputId: string, saveUrl: string): void; } }