Fix prefetch
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"] {
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user