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, paginated_table_content,
) )
from common.components.search_select import ( from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect, FilterSelect,
LabeledOption, LabeledOption,
SearchSelect, SearchSelect,
@@ -87,6 +88,7 @@ __all__ = [
"Popover", "Popover",
"PopoverTruncated", "PopoverTruncated",
"SearchField", "SearchField",
"DEFAULT_PREFETCH",
"FilterSelect", "FilterSelect",
"LabeledOption", "LabeledOption",
"SearchSelect", "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.core import Component
from common.components.primitives import Label, Span 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): 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 # 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. # 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 # Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
# mutually exclusive with value pills (selecting one clears the value set). # mutually exclusive with value pills (selecting one clears the value set).
# Must match JS PRESENCE_MODIFIERS in search_select.js. # Must match JS PRESENCE_MODIFIERS in search_select.js.
@@ -189,7 +187,7 @@ def _model_filter(
modifier=modifier, modifier=modifier,
modifier_options=_modifier_options(nullable, m2m_modifiers), modifier_options=_modifier_options(nullable, m2m_modifiers),
search_url=search_url, 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. # used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25 _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 ─────────────────────────────────────────────────── # ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS) # Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added # so the filter combobox is fully self-styled — nothing in input.css. JS-added
+1 -1
View File
@@ -206,7 +206,7 @@ textarea:disabled {
label { label {
@apply mb-2.5 text-sm font-medium text-heading; @apply mb-2.5 text-sm font-medium text-heading;
} }
input:not([type="checkbox"]) { 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; @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"] { input[type="checkbox"] {
+4
View File
@@ -3,6 +3,7 @@ from django.db import transaction
from django.db.models import OuterRef, Subquery from django.db.models import OuterRef, Subquery
from common.components import ( from common.components import (
DEFAULT_PREFETCH,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
searchselect_selected, searchselect_selected,
@@ -75,6 +76,7 @@ class SearchSelectWidget(forms.Widget):
multi_select=False, multi_select=False,
items_visible=5, items_visible=5,
items_scroll=10, items_scroll=10,
prefetch=DEFAULT_PREFETCH,
always_visible=False, always_visible=False,
placeholder="Search…", placeholder="Search…",
attrs=None, attrs=None,
@@ -85,6 +87,7 @@ class SearchSelectWidget(forms.Widget):
self.multi_select = multi_select self.multi_select = multi_select
self.items_visible = items_visible self.items_visible = items_visible
self.items_scroll = items_scroll self.items_scroll = items_scroll
self.prefetch = prefetch
self.always_visible = always_visible self.always_visible = always_visible
self.placeholder = placeholder self.placeholder = placeholder
@@ -107,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
multi_select=self.multi_select, multi_select=self.multi_select,
items_visible=self.items_visible, items_visible=self.items_visible,
items_scroll=self.items_scroll, items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible, always_visible=self.always_visible,
placeholder=self.placeholder, placeholder=self.placeholder,
id=(attrs or {}).get("id", ""), 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); font-weight: var(--font-weight-medium);
color: var(--color-heading); color: var(--color-heading);
} }
input:not([type="checkbox"]) { input:not([type="checkbox"]):not([data-search-select-search]) {
margin-bottom: calc(var(--spacing) * 3); margin-bottom: calc(var(--spacing) * 3);
display: block; display: block;
width: 100%; width: 100%;
+48 -34
View File
@@ -60,15 +60,29 @@
var pendingRequest = null; // in-flight AbortController, so newer queries win var pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false; 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() { function showPanel() {
options.classList.remove("hidden"); if (alwaysVisible || hasVisibleContent()) {
options.classList.remove("hidden");
}
} }
function hidePanel() { function hidePanel() {
if (!alwaysVisible) options.classList.add("hidden"); if (!alwaysVisible) options.classList.add("hidden");
} }
function setNoResults(visible) { 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) ── // ── Highlight tracking (filter mode) ──
@@ -198,7 +212,7 @@
renderRows(items); renderRows(items);
// Re-apply the live query: the box may hold more text than was sent. // Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0); setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim()); autoHighlight(search.value.trim());
}) })
.catch(function (error) { .catch(function (error) {
if (error && error.name === "AbortError") return; // superseded if (error && error.name === "AbortError") return; // superseded
@@ -214,7 +228,6 @@
// so the client-side filter is authoritative. // so the client-side filter is authoritative.
function runSearch() { function runSearch() {
var query = search.value.trim(); var query = search.value.trim();
showPanel();
if (searchUrl) { if (searchUrl) {
filterRows(query); filterRows(query);
setNoResults(false); setNoResults(false);
@@ -225,7 +238,8 @@
} else { } else {
setNoResults(filterRows(query) === 0); setNoResults(filterRows(query) === 0);
} }
if (isFilter) autoHighlight(query); autoHighlight(query);
showPanel();
} }
// ── Single-select combobox: the search box shows the committed label; // ── Single-select combobox: the search box shows the committed label;
@@ -238,7 +252,6 @@
search.value = ""; search.value = "";
container._searchSelectDirty = false; container._searchSelectDirty = false;
} }
showPanel();
if (searchUrl) { if (searchUrl) {
if (prefetch && !hasPrefetched) { if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced). // Seed the window immediately on first open (not debounced).
@@ -248,12 +261,13 @@
// Show whatever is already loaded; the server decides no-results. // Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim()); filterRows(search.value.trim());
setNoResults(false); setNoResults(false);
if (isFilter) autoHighlight(search.value.trim()); autoHighlight(search.value.trim());
} }
} else { } else {
setNoResults(filterRows(search.value.trim()) === 0); setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim()); autoHighlight(search.value.trim());
} }
showPanel();
}); });
search.addEventListener("input", function () { search.addEventListener("input", function () {
clearHighlight(); clearHighlight();
@@ -278,42 +292,42 @@
}); });
} }
// ── Keyboard navigation (filter mode) ── // ── Keyboard navigation (both form and filter modes) ──
search.addEventListener("keydown", function (event) { search.addEventListener("keydown", function (event) {
if (!isFilter) return;
var key = event.key; var key = event.key;
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") { if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
var visible = getVisibleOptions(); var visible = getVisibleOptions();
if (visible.length === 0) { if (visible.length === 0) {
if (key === "Escape") hidePanel(); if (key === "Escape") hidePanel();
return; 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(); event.preventDefault();
showPanel(); var option = optionFromRow(highlightedRow);
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1; if (isFilter) {
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);
addFilterPill(option, "include"); addFilterPill(option, "include");
search.value = ""; search.value = "";
clearHighlight(); } else {
hidePanel(); selectOption(option);
} }
} else if (key === "Escape") {
clearHighlight(); clearHighlight();
hidePanel(); hidePanel();
} }
} else if (key === "Escape") {
clearHighlight();
hidePanel();
} }
}); });