Convert filter_bar.js to TypeScript (issue #17)
- Add ts/filter_bar.ts: typed port of the filter bar. Criterion / PillEntry / RangeField / DeselectableRadio interfaces replace the loose objects and the radio.wasChecked custom property; var → const/let throughout - Window entry points (applyFilterBar/clearFilterBar/toggleStringFilterInput/ showPresetNameInput/savePreset) declared in ts/globals.d.ts; readSearchSelect now called as window.readSearchSelect - Drop the dead selectValue helper; factor the repeated path→mode mapping into presetMode() - Point the FilterBar component Media and every e2e/test reference at the compiled dist/filter_bar.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
_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
|
# range_slider.js wires RangeSlider; ts/filter_bar.ts wires the bar chrome
|
||||||
# (Apply/Clear, presets, search injection). Widget media (search_select.js,
|
# (Apply/Clear, presets, search injection). Widget media (dist/search_select.js,
|
||||||
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
|
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
|
||||||
_RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",))
|
_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:
|
def _filter_parse(filter_json: str) -> dict:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/range_slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/search_select.js" type="module"></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/range_slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/search_select.js" type="module"></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/range_slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/search_select.js" type="module"></script>
|
||||||
<script src="/static/js/date_range_picker.js" defer></script>
|
<script src="/static/js/date_range_picker.js" defer></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/range_slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/search_select.js" type="module"></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/range_slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/search_select.js" type="module"></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -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 <select> element's value, or "" if not found. */
|
|
||||||
function selectValue(form, name) {
|
|
||||||
var el = form.querySelector('[name="' + name + '"]');
|
|
||||||
return el ? el.value : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read an <input type="number"> 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 <input> 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 <input type="date">; 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 =
|
|
||||||
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
|
||||||
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 <li>
|
|
||||||
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 =
|
|
||||||
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -169,7 +169,7 @@ class RealComponentMediaTest(unittest.TestCase):
|
|||||||
from common.components import FilterBar
|
from common.components import FilterBar
|
||||||
|
|
||||||
media = collect_media(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("dist/search_select.js", media.js)
|
||||||
self.assertIn("range_slider.js", media.js)
|
self.assertIn("range_slider.js", media.js)
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
"""The games list view passes no scripts= argument; the filter bar's
|
"""The games list view passes no scripts= argument; the filter bar's
|
||||||
components declare their JS and Page() collects it."""
|
components declare their JS and Page() collects it."""
|
||||||
html = self.get("games:list_games").content.decode()
|
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/dist/search_select.js", html)
|
||||||
self.assertIn("js/range_slider.js", html)
|
self.assertIn("js/range_slider.js", html)
|
||||||
|
|
||||||
|
|||||||
@@ -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 <input type="number"> value, or "" if not found. */
|
||||||
|
function numberValue(form: HTMLElement, name: string): number | "" {
|
||||||
|
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
||||||
|
if (!element || element.value === "") return "";
|
||||||
|
const value = parseFloat(element.value);
|
||||||
|
return isNaN(value) ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a raw <input> value as string, or "" if not found. */
|
||||||
|
function stringValue(form: HTMLElement, name: string): string {
|
||||||
|
const element = form.querySelector<HTMLInputElement>(`[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<string, unknown> {
|
||||||
|
const filter: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// ── Search field ──
|
||||||
|
const searchInput = form.querySelector<HTMLInputElement>('[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<HTMLElement>(
|
||||||
|
'[data-search-select][data-search-select-mode="filter"]'
|
||||||
|
);
|
||||||
|
widgets.forEach((widget) => {
|
||||||
|
const field = widget.getAttribute("data-name");
|
||||||
|
if (!field) return;
|
||||||
|
const included = parseJSONAttr<PillEntry>(widget, "data-included");
|
||||||
|
const excluded = parseJSONAttr<PillEntry>(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<HTMLInputElement>(
|
||||||
|
`[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<HTMLInputElement>(`[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<HTMLInputElement>(
|
||||||
|
`[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 <input type="date">; 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<T>(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 =
|
||||||
|
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire up click handlers for preset delete buttons. */
|
||||||
|
function setupPresetDeleteHandlers(container: HTMLElement): void {
|
||||||
|
const deleteLinks = container.querySelectorAll<HTMLAnchorElement>("[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 <li>
|
||||||
|
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 =
|
||||||
|
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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<HTMLInputElement>('input[type="text"]');
|
||||||
|
if (!textInput) return;
|
||||||
|
|
||||||
|
// Find the currently checked radio in the container
|
||||||
|
const checkedRadio = container.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('[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<DeselectableRadio>('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<DeselectableRadio>(`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<HTMLInputElement>("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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
Vendored
+5
@@ -4,5 +4,10 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
readSearchSelect(form: HTMLElement): void;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user