Unify UI for filter modifiers
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m16s

This commit is contained in:
2026-06-09 08:46:19 +02:00
parent 737dd9275b
commit 1c9fb474df
7 changed files with 185 additions and 305 deletions
+9 -10
View File
@@ -68,22 +68,21 @@
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, while the
// match mode (INCLUDES/INCLUDES_ALL/EXCLUDES) governs how the include set
// matches. Fields without a data-match attribute have no match-mode select
// — the full modifier lives in data-modifier (e.g. enum/choice fields).
var presence = widget.getAttribute("data-modifier");
var matchVal = widget.getAttribute("data-match");
var match = matchVal || presence || "INCLUDES";
if (presence === "NOT_NULL" || presence === "IS_NULL") {
filter[field] = { modifier: presence };
// 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: match,
modifier: modifier || "INCLUDES",
};
}
});
+38 -17
View File
@@ -26,6 +26,12 @@
var DEBOUNCE_MS = 100;
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
// These modifiers are mutually exclusive with value pills — selecting
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
// INCLUDES_ONLY) coexist with value pills.
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (element) {
if (element._searchSelectInit) return;
@@ -356,9 +362,17 @@
}
// Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears an active modifier — the two are mutually exclusive.
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills.
function addFilterPill(option, kind) {
clearModifier();
var modPill = pills.querySelector("[data-search-select-modifier]");
if (modPill) {
var modVal = modPill.getAttribute("data-search-select-modifier");
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) {
clearModifier();
}
}
var existing = pills.querySelector(
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
);
@@ -377,24 +391,36 @@
return pill;
}
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
// value pills — they are mutually exclusive. Non-presence modifiers
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
function setModifier(modifierValue, label) {
pills.innerHTML = "";
// Remove any existing modifier pill to avoid duplicates.
clearModifierPill();
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) {
pills.innerHTML = "";
}
var pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.appendChild(pill);
pills.insertBefore(pill, pills.firstChild);
container.setAttribute("data-modifier", modifierValue);
hidePanel();
emitChange(null);
}
function clearModifier() {
// Remove the modifier pill and its container attribute. Safe to call when
// there is no modifier pill (no-op). Does not touch value pills.
function clearModifierPill() {
var modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
}
function clearModifier() {
clearModifierPill();
}
function optionFromRow(row) {
if (row._searchSelectOption) return row._searchSelectOption;
var data = {};
@@ -458,12 +484,12 @@
var pill = removeButton.closest("[data-pill]");
if (!pill) return;
if (isFilter) {
// Filter pills have no hidden input; a modifier pill also clears the
// container flag.
// Filter pills have no hidden input.
if (pill.hasAttribute("data-search-select-modifier")) {
container.removeAttribute("data-modifier");
clearModifierPill();
} else {
pill.remove();
}
pill.remove();
emitChange(null);
return;
}
@@ -538,8 +564,8 @@
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
var pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier;
return;
modifier = pillModifier; // last modifier pill wins
return; // skip value extraction for this pill
}
var value = pill.getAttribute("data-value");
var label = pill.getAttribute("data-label") || "";
@@ -554,11 +580,6 @@
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
else container.removeAttribute("data-modifier");
// The match-mode <select> (any/all/none) governs how the include set
// matches; its value is the criterion modifier. A native control, so its
// value is read directly — no pill bookkeeping.
var matchSelect = container.querySelector("[data-search-select-match]");
if (matchSelect) container.setAttribute("data-match", matchSelect.value);
return;
}
var values = pills