Expand the ss namespace prefix to search-select everywhere

Spell out the abbreviated data-ss-* hook attributes (data-search-select-option,
-label, -mode, -template, -action, -type, -modifier, -modifier-option, -pills,
-search, -options, -no-results) and the JS expando properties (_searchSelectInit,
_searchSelectLabel, _searchSelectDirty, _searchSelectOption) across components,
JS, and tests — no abbreviations left in the widget's hooks.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
Claude
2026-06-08 14:13:05 +00:00
committed by Lukáš Kucharczyk
parent a06e772e42
commit 15bb3ce1b9
7 changed files with 106 additions and 97 deletions
+6 -2
View File
@@ -395,7 +395,7 @@ def Pill(
are JS hooks only (no CSS attached). ``value`` (when set) becomes
``data-value``; extra ``attributes`` are appended to the outer span.
``label_slot=True`` wraps the label in a ``<span data-ss-label>`` so JS can
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``).
"""
@@ -407,7 +407,11 @@ def Pill(
pill_attrs.extend(attributes)
label_child: HTMLTag = (
Component(tag_name="span", attributes=[("data-ss-label", "")], children=[label])
Component(
tag_name="span",
attributes=[("data-search-select-label", "")],
children=[label],
)
if label_slot
else label
)
+24 -19
View File
@@ -121,21 +121,23 @@ def _hidden_input(name: str, value) -> SafeText:
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
"""A ``<span data-ss-label>`` holding a row/pill's visible label. JS fills this
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side."""
attributes: list[HTMLAttribute] = [("data-ss-label", "")]
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
if extra_class:
attributes.append(("class", extra_class))
return Component(tag_name="span", attributes=attributes, children=[text])
def _template(name: str, node: SafeText) -> SafeText:
"""Wrap a prototype row/pill in an inert ``<template data-ss-template=name>`` that
"""Wrap a prototype row/pill in an inert ``<template data-search-select-template=name>`` that
the JS clones. Rendering the prototype with the real component keeps the JS
free of any markup or class strings."""
return Component(
tag_name="template", attributes=[("data-ss-template", name)], children=[node]
tag_name="template",
attributes=[("data-search-select-template", name)],
children=[node],
)
@@ -147,7 +149,7 @@ def _option_row(option: SearchSelectOption) -> SafeText:
return Component(
tag_name="div",
attributes=[
("data-ss-option", ""),
("data-search-select-option", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("class", _OPTION_ROW_CLASS),
@@ -183,14 +185,17 @@ def _combobox_shell(
no_results = Component(
tag_name="div",
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
attributes=[
("data-search-select-no-results", ""),
("class", _NO_RESULTS_CLASS),
],
children=["No results"],
)
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
options_panel = Component(
tag_name="div",
attributes=[
("data-ss-options", ""),
("data-search-select-options", ""),
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
("class", options_class),
],
@@ -228,7 +233,7 @@ def SearchSelect(
# Multi-select renders a removable Pill per value; single-select renders no
# pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-ss-pills]` so the JS reads/writes values uniformly.
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = []
search_value = ""
if multi_select:
@@ -250,13 +255,13 @@ def SearchSelect(
pills = Component(
tag_name="div",
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attrs: list[HTMLAttribute] = [
("data-ss-search", ""),
("data-search-select-search", ""),
("type", "text"),
("placeholder", placeholder),
("autocomplete", "off"),
@@ -332,7 +337,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
("data-pill", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("data-ss-type", kind),
("data-search-select-type", kind),
*_data_attributes(option["data"]),
],
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
@@ -346,7 +351,7 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
attributes=[
("class", _FILTER_MODIFIER_PILL_CLASS),
("data-pill", ""),
("data-ss-modifier", modifier_value),
("data-search-select-modifier", modifier_value),
],
children=[_label_slot(label), _filter_remove_button()],
)
@@ -357,7 +362,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
tag_name="button",
attributes=[
("type", "button"),
("data-ss-action", action),
("data-search-select-action", action),
("class", _FILTER_ACTION_BUTTON_CLASS),
("title", title),
],
@@ -370,7 +375,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
return Component(
tag_name="div",
attributes=[
("data-ss-option", ""),
("data-search-select-option", ""),
("data-value", str(value)),
("data-label", label),
("class", _FILTER_OPTION_ROW_CLASS),
@@ -390,12 +395,12 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
"""A pinned pseudo-option row. It carries no ``data-ss-option`` so the text
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel."""
return Component(
tag_name="div",
attributes=[
("data-ss-modifier-option", modifier_value),
("data-search-select-modifier-option", modifier_value),
("data-label", label),
("class", _FILTER_MODIFIER_ROW_CLASS),
],
@@ -454,13 +459,13 @@ def FilterSelect(
pills = Component(
tag_name="div",
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attributes: list[HTMLAttribute] = [
("data-ss-search", ""),
("data-search-select-search", ""),
("type", "text"),
("placeholder", placeholder),
("autocomplete", "off"),
@@ -491,7 +496,7 @@ def FilterSelect(
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-ss-mode", "filter"),
("data-search-select-mode", "filter"),
("data-name", field_name),
("data-search-url", search_url),
("data-multi", "true"),
+3 -3
View File
@@ -59,10 +59,10 @@
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── FilterSelect widgets (data-ss-mode="filter") ──
// ── 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-ss-mode="filter"]');
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");
@@ -86,7 +86,7 @@
// ── Session-specific fields ──
var pageIsSessions =
!!form.querySelector('[data-search-select][data-ss-mode="filter"][data-name="game"]');
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
+38 -38
View File
@@ -6,18 +6,18 @@
* focus clears it to search, picking an option fills it), with a lone hidden
* <input> carrying the value. Both keep hidden inputs so Django validation works.
*
* Filter mode (data-ss-mode="filter", rendered by FilterSelect): value rows
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
* carry +/ buttons that add include (✓) / exclude (✗) pills, plus pinned
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar.
*
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
* element._ssInit.
* element._searchSelectInit.
*
* Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect /
* FilterSelect). The JS only fills in the label slot ([data-ss-label]), value,
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
* and data-* attributes — so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here.
*/
@@ -28,28 +28,28 @@
function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (element) {
if (element._ssInit) return;
element._ssInit = true;
if (element._searchSelectInit) return;
element._searchSelectInit = true;
initWidget(element);
});
}
function initWidget(container) {
var search = container.querySelector("[data-ss-search]");
var options = container.querySelector("[data-ss-options]");
var pills = container.querySelector("[data-ss-pills]");
var search = container.querySelector("[data-search-select-search]");
var options = container.querySelector("[data-search-select-options]");
var pills = container.querySelector("[data-search-select-pills]");
if (!search || !options || !pills) return;
var name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url");
var isFilter = container.getAttribute("data-ss-mode") === "filter";
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
var multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
var syncUrl = container.getAttribute("data-sync-url") === "true";
var noResults = options.querySelector("[data-ss-no-results]");
var noResults = options.querySelector("[data-search-select-no-results]");
var debounceTimer = null;
var pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false;
@@ -67,7 +67,7 @@
// ── Render server-fetched rows into the panel ──
function renderRows(items) {
options.querySelectorAll("[data-ss-option]").forEach(function (row) {
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
row.remove();
});
items.slice(0, itemsScroll).forEach(function (item) {
@@ -79,14 +79,14 @@
// ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
function cloneTemplate(name) {
var template = container.querySelector('template[data-ss-template="' + name + '"]');
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
return template
? template.content.firstElementChild.cloneNode(true)
: null;
}
function setLabel(node, label) {
var slot = node.querySelector("[data-ss-label]");
var slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label;
}
@@ -106,7 +106,7 @@
row.setAttribute("data-label", option.label);
applyData(row, option.data);
setLabel(row, option.label);
row._ssOption = option;
row._searchSelectOption = option;
return row;
}
@@ -115,7 +115,7 @@
function filterRows(query) {
var lower = query.toLowerCase();
var visibleCount = 0;
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none";
@@ -170,13 +170,13 @@
// ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._ssLabel = search.value;
if (!multi) container._searchSelectLabel = search.value;
search.addEventListener("focus", function () {
if (!multi) {
// Hide the committed label so the box becomes a fresh search field.
search.value = "";
container._ssDirty = false;
container._searchSelectDirty = false;
}
showPanel();
if (searchUrl) {
@@ -194,22 +194,22 @@
}
});
search.addEventListener("input", function () {
if (!multi) container._ssDirty = true;
if (!multi) container._searchSelectDirty = true;
runSearch();
});
if (!multi) {
search.addEventListener("blur", function () {
// Defer so an option click (which fires before blur settles) wins.
setTimeout(function () {
if (container._ssDirty && search.value.trim() === "") {
if (container._searchSelectDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect.
pills.innerHTML = "";
container._ssLabel = "";
container._searchSelectLabel = "";
emitChange(null);
} else {
// Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection).
search.value = container._ssLabel || "";
search.value = container._searchSelectLabel || "";
}
}, 120);
});
@@ -226,27 +226,27 @@
handleFilterOptionClick(e);
return;
}
var row = event.target.closest("[data-ss-option]");
var row = event.target.closest("[data-search-select-option]");
if (!row) return;
selectOption(optionFromRow(row));
});
function handleFilterOptionClick(e) {
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
var modifierRow = event.target.closest("[data-ss-modifier-option]");
var modifierRow = event.target.closest("[data-search-select-modifier-option]");
if (modifierRow) {
setModifier(
modifierRow.getAttribute("data-ss-modifier-option"),
modifierRow.getAttribute("data-search-select-modifier-option"),
modifierRow.getAttribute("data-label")
);
return;
}
// Include / exclude button on a value row.
var button = event.target.closest("[data-ss-action]");
var button = event.target.closest("[data-search-select-action]");
if (!button) return;
var row = button.closest("[data-ss-option]");
var row = button.closest("[data-search-select-option]");
if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-ss-action"));
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
}
// Add (or re-type) an include/exclude pill for a value. Selecting any value
@@ -274,7 +274,7 @@
function setModifier(modifierValue, label) {
pills.innerHTML = "";
var pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-ss-modifier", modifierValue);
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.appendChild(pill);
container.setAttribute("data-modifier", modifierValue);
@@ -283,13 +283,13 @@
}
function clearModifier() {
var modifierPill = pills.querySelector("[data-ss-modifier]");
var modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
}
function optionFromRow(row) {
if (row._ssOption) return row._ssOption;
if (row._searchSelectOption) return row._searchSelectOption;
var data = {};
Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") {
@@ -310,12 +310,12 @@
}
} else {
// Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-ss-pills] for submission.
// lone hidden input under [data-search-select-pills] for submission.
pills.innerHTML = "";
pills.appendChild(buildHidden(option.value));
search.value = option.label;
container._ssLabel = option.label;
container._ssDirty = false;
container._searchSelectLabel = option.label;
container._searchSelectDirty = false;
hidePanel();
}
emitChange(option);
@@ -353,7 +353,7 @@
if (isFilter) {
// Filter pills have no hidden input; a modifier pill also clears the
// container flag.
if (pill.hasAttribute("data-ss-modifier")) {
if (pill.hasAttribute("data-search-select-modifier")) {
container.removeAttribute("data-modifier");
}
pill.remove();
@@ -422,20 +422,20 @@
// bar to read.
window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-ss-pills]");
if (container.getAttribute("data-ss-mode") === "filter") {
var pills = container.querySelector("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
var included = [];
var excluded = [];
var modifier = "";
if (pills) {
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
var pillModifier = pill.getAttribute("data-ss-modifier");
var pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier;
return;
}
var value = pill.getAttribute("data-value");
if (pill.getAttribute("data-ss-type") === "exclude") {
if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push(value);
} else {
included.push(value);
+2 -2
View File
@@ -100,8 +100,8 @@ class FilterBarRenderingTest(TestCase):
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn('data-ss-mode="filter"', html)
self.assertIn('data-ss-type="include"', html) # rendered as an include pill
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('data-search-select-type="include"', html) # rendered as an include pill
self.assertIn('data-value="f"', html) # selected status reflected in widget
self.assertIn("Finished", html) # ...with its label
self.assertNoEscapedTags(html)
+1 -1
View File
@@ -239,7 +239,7 @@ class TestFilterBarRendering:
def test_status_uses_filter_select(self):
html = str(FilterBar())
assert 'data-ss-mode="filter"' in html
assert 'data-search-select-mode="filter"' in html
assert 'data-name="status"' in html
def test_mastered_not_checked_by_default(self):
+32 -32
View File
@@ -52,7 +52,7 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_empty_options_renders_no_results_scaffold(self):
html = SearchSelect(name="games")
self.assertIn("data-ss-no-results", html)
self.assertIn("data-search-select-no-results", html)
self.assertIn("No results", html)
def test_outer_container_carries_config(self):
@@ -91,13 +91,13 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_search_box_has_no_name(self):
html = SearchSelect(name="games")
self.assertIn("data-ss-search", html)
self.assertIn("data-search-select-search", html)
# container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self):
html = SearchSelect(name="t", options=[("1", "One")])
self.assertIn('data-ss-option=""', html)
self.assertIn('data-search-select-option=""', html)
self.assertIn('data-value="1"', html)
self.assertIn("One", html)
@@ -107,27 +107,27 @@ class SearchSelectComponentTest(unittest.TestCase):
)
# No pre-rendered rows in the live panel; the row prototype lives only in
# the cloneable <template>.
panel = html.split("data-ss-template")[0]
self.assertNotIn('data-ss-option=""', panel)
self.assertIn('data-ss-template="row"', html)
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
def test_templates_carry_label_slot_for_js_cloning(self):
# The dynamic shapes the JS clones expose a [data-ss-label] slot so the JS
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
# only fills text — classes/structure stay server-side.
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
self.assertIn('data-ss-template="row"', html)
self.assertIn('data-ss-template="pill"', html)
self.assertIn("data-ss-label", html)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-template="pill"', html)
self.assertIn("data-search-select-label", html)
def test_shell_region_order_pills_search_options(self):
# The shared shell assembles the three regions in a fixed order; option
# rows precede the trailing no-results node inside the options panel.
html = SearchSelect(name="t", options=[("1", "One")])
pills = html.index("data-ss-pills")
search = html.index("data-ss-search")
options = html.index("data-ss-options")
option_row = html.index('data-ss-option=""')
no_results = html.index("data-ss-no-results")
pills = html.index("data-search-select-pills")
search = html.index("data-search-select-search")
options = html.index("data-search-select-options")
option_row = html.index('data-search-select-option=""')
no_results = html.index("data-search-select-no-results")
self.assertLess(pills, search)
self.assertLess(search, options)
self.assertLess(options, option_row)
@@ -144,15 +144,15 @@ class FilterSelectComponentTest(unittest.TestCase):
html = FilterSelect(field_name="type")
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
self.assertIn("data-search-select", html)
self.assertIn('data-ss-mode="filter"', html)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('data-name="type"', html)
# No name is submitted — state is read from the DOM into the filter JSON.
self.assertEqual(html.count(' name="type"'), 0)
def test_value_rows_have_include_exclude_buttons(self):
html = FilterSelect(field_name="type", options=[("g", "Game")])
self.assertIn('data-ss-action="include"', html)
self.assertIn('data-ss-action="exclude"', html)
self.assertIn('data-search-select-action="include"', html)
self.assertIn('data-search-select-action="exclude"', html)
self.assertIn('data-value="g"', html)
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
@@ -162,22 +162,22 @@ class FilterSelectComponentTest(unittest.TestCase):
included=[("1", "Steam")],
excluded=[("2", "GOG")],
)
# Labels live in a [data-ss-label] slot (so JS can fill clones); the ✓/✗
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
# symbol is a sibling text node.
self.assertIn('data-ss-type="include"', html)
self.assertIn('data-search-select-type="include"', html)
self.assertIn("", html)
self.assertIn(">Steam</span>", html)
self.assertIn('data-ss-type="exclude"', html)
self.assertIn('data-search-select-type="exclude"', html)
self.assertIn("", html)
self.assertIn(">GOG</span>", html)
self.assertIn("line-through", html) # excluded pill styling
def test_modifier_options_render_pinned_rows(self):
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
# Pinned pseudo-options carry data-ss-modifier-option, never data-ss-option,
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
# so the text filter leaves them visible.
self.assertIn('data-ss-modifier-option="NOT_NULL"', html)
self.assertIn('data-ss-modifier-option="IS_NULL"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
def test_active_modifier_replaces_value_pills(self):
html = FilterSelect(
@@ -189,11 +189,11 @@ class FilterSelectComponentTest(unittest.TestCase):
)
# The lone modifier pill is shown; include/exclude pills are suppressed.
# (Scope the check to the live pills region — the cloneable pill <template>s
# legitimately contain data-ss-type.)
pills_region = html.split("data-ss-template")[0]
self.assertIn('data-ss-modifier="IS_NULL"', html)
# legitimately contain data-search-select-type.)
pills_region = html.split("data-search-select-template")[0]
self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html)
self.assertNotIn('data-ss-type="include"', pills_region)
self.assertNotIn('data-search-select-type="include"', pills_region)
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
@@ -205,10 +205,10 @@ class FilterSelectComponentTest(unittest.TestCase):
)
# No value rows in the live panel (they're fetched); the row prototype
# lives only in a <template>.
panel = html.split("data-ss-template")[0]
self.assertNotIn('data-ss-option=""', panel)
self.assertIn('data-ss-template="row"', html)
self.assertIn('data-ss-modifier-option="NOT_NULL"', html) # still pinned
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) # still pinned
self.assertIn('data-prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self):