Fix prefetch

This commit is contained in:
2026-06-09 11:37:41 +02:00
parent 6f4841eaaa
commit ed086c9702
7 changed files with 64 additions and 41 deletions
+2
View File
@@ -41,6 +41,7 @@ from common.components.primitives import (
paginated_table_content,
)
from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect,
LabeledOption,
SearchSelect,
@@ -87,6 +88,7 @@ __all__ = [
"Popover",
"PopoverTruncated",
"SearchField",
"DEFAULT_PREFETCH",
"FilterSelect",
"LabeledOption",
"SearchSelect",
+2 -4
View File
@@ -7,7 +7,7 @@ from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component
from common.components.primitives import Label, Span
from common.components.search_select import FilterSelect, LabeledOption
from common.components.search_select import DEFAULT_PREFETCH, FilterSelect, LabeledOption
class FilterChoice(NamedTuple):
@@ -91,8 +91,6 @@ def _parse_bool(existing: dict, key: str) -> bool:
# option set; model-backed fields fetch from a search endpoint on demand, with
# labels embedded in the filter JSON so pills render without a DB round-trip.
_FILTER_PREFETCH = 20
# Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
# mutually exclusive with value pills (selecting one clears the value set).
# Must match JS PRESENCE_MODIFIERS in search_select.js.
@@ -189,7 +187,7 @@ def _model_filter(
modifier=modifier,
modifier_options=_modifier_options(nullable, m2m_modifiers),
search_url=search_url,
prefetch=_FILTER_PREFETCH,
prefetch=DEFAULT_PREFETCH,
)
+5
View File
@@ -67,6 +67,11 @@ _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
# used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25
# Default number of rows to fetch on first focus when a search_url is set.
# Shared by filter and form widgets so the dropdown is populated for keyboard
# navigation as soon as the user opens it.
DEFAULT_PREFETCH = 20
# ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
+2 -2
View File
@@ -206,8 +206,8 @@ textarea:disabled {
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
input:not([type="checkbox"]):not([data-search-select-search]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
+4
View File
@@ -3,6 +3,7 @@ from django.db import transaction
from django.db.models import OuterRef, Subquery
from common.components import (
DEFAULT_PREFETCH,
SearchSelect,
SearchSelectOption,
searchselect_selected,
@@ -75,6 +76,7 @@ class SearchSelectWidget(forms.Widget):
multi_select=False,
items_visible=5,
items_scroll=10,
prefetch=DEFAULT_PREFETCH,
always_visible=False,
placeholder="Search…",
attrs=None,
@@ -85,6 +87,7 @@ class SearchSelectWidget(forms.Widget):
self.multi_select = multi_select
self.items_visible = items_visible
self.items_scroll = items_scroll
self.prefetch = prefetch
self.always_visible = always_visible
self.placeholder = placeholder
@@ -107,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
multi_select=self.multi_select,
items_visible=self.items_visible,
items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
+1 -1
View File
@@ -4370,7 +4370,7 @@ form input:disabled, select:disabled, textarea:disabled {
font-weight: var(--font-weight-medium);
color: var(--color-heading);
}
input:not([type="checkbox"]) {
input:not([type="checkbox"]):not([data-search-select-search]) {
margin-bottom: calc(var(--spacing) * 3);
display: block;
width: 100%;
+48 -34
View File
@@ -60,15 +60,29 @@
var pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false;
function hasVisibleContent() {
var optionRows = options.querySelectorAll("[data-search-select-option]");
for (var i = 0; i < optionRows.length; i++) {
if (optionRows[i].style.display !== "none") return true;
}
if (noResults && !noResults.classList.contains("hidden")) return true;
if (options.querySelector("[data-search-select-modifier-option]")) return true;
return false;
}
function showPanel() {
options.classList.remove("hidden");
if (alwaysVisible || hasVisibleContent()) {
options.classList.remove("hidden");
}
}
function hidePanel() {
if (!alwaysVisible) options.classList.add("hidden");
}
function setNoResults(visible) {
if (noResults) noResults.classList.toggle("hidden", !visible);
if (!noResults) return;
noResults.classList.toggle("hidden", !visible);
if (visible) showPanel();
}
// ── Highlight tracking (filter mode) ──
@@ -198,7 +212,7 @@
renderRows(items);
// Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim());
autoHighlight(search.value.trim());
})
.catch(function (error) {
if (error && error.name === "AbortError") return; // superseded
@@ -214,7 +228,6 @@
// so the client-side filter is authoritative.
function runSearch() {
var query = search.value.trim();
showPanel();
if (searchUrl) {
filterRows(query);
setNoResults(false);
@@ -225,7 +238,8 @@
} else {
setNoResults(filterRows(query) === 0);
}
if (isFilter) autoHighlight(query);
autoHighlight(query);
showPanel();
}
// ── Single-select combobox: the search box shows the committed label;
@@ -238,7 +252,6 @@
search.value = "";
container._searchSelectDirty = false;
}
showPanel();
if (searchUrl) {
if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced).
@@ -248,12 +261,13 @@
// Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim());
setNoResults(false);
if (isFilter) autoHighlight(search.value.trim());
autoHighlight(search.value.trim());
}
} else {
setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim());
autoHighlight(search.value.trim());
}
showPanel();
});
search.addEventListener("input", function () {
clearHighlight();
@@ -278,42 +292,42 @@
});
}
// ── Keyboard navigation (filter mode) ──
// ── Keyboard navigation (both form and filter modes) ──
search.addEventListener("keydown", function (event) {
if (!isFilter) return;
var key = event.key;
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") {
var visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
var visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key === "ArrowDown") {
if (key === "ArrowDown") {
event.preventDefault();
showPanel();
var downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(downIdx + 1) % visible.length]);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
var upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
showPanel();
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
var next = visible[(idx + 1) % visible.length];
highlightOption(next);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
var prev = visible[(idx - 1 + visible.length) % visible.length];
highlightOption(prev);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
var option = optionFromRow(highlightedRow);
var option = optionFromRow(highlightedRow);
if (isFilter) {
addFilterPill(option, "include");
search.value = "";
clearHighlight();
hidePanel();
} else {
selectOption(option);
}
} else if (key === "Escape") {
clearHighlight();
hidePanel();
}
} else if (key === "Escape") {
clearHighlight();
hidePanel();
}
});