Add prefetch + instant-local/debounced-remote search to combobox
Introduce a general 'prefetch' option (rows to load on first open, default 0 = unchanged) carried as data-prefetch. Rework the JS search so a search_url widget filters its loaded window instantly on every keystroke while issuing a debounced server request for the rest, with an AbortController so a slower earlier response can never overwrite a newer one. No-results stays hidden until the server response decides it, avoiding a flash over an incomplete window. On first focus a prefetch-enabled widget seeds its window immediately. Rename single-letter locals to full words while reworking these functions. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
@@ -7,6 +7,15 @@ hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
|
|||||||
This module imports only from ``common.components`` — it has no Django-forms or
|
This module imports only from ``common.components`` — it has no Django-forms or
|
||||||
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
||||||
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
||||||
|
|
||||||
|
Option sourcing follows two axes. *Population*: options are either rendered
|
||||||
|
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
|
||||||
|
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
|
||||||
|
filtering is purely client-side; with a ``search_url`` the loaded rows are a
|
||||||
|
window, so the JS filters the loaded rows instantly on each keystroke while
|
||||||
|
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
|
||||||
|
first open, ``0`` = none) seeds that window so the panel is populated before the
|
||||||
|
user types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
@@ -138,6 +147,7 @@ def SearchSelect(
|
|||||||
always_visible: bool = False,
|
always_visible: bool = False,
|
||||||
items_visible: int = 5,
|
items_visible: int = 5,
|
||||||
items_scroll: int = 10,
|
items_scroll: int = 10,
|
||||||
|
prefetch: int = 0,
|
||||||
placeholder: str = "Search…",
|
placeholder: str = "Search…",
|
||||||
id: str = "",
|
id: str = "",
|
||||||
sync_url: bool = False,
|
sync_url: bool = False,
|
||||||
@@ -200,6 +210,7 @@ def SearchSelect(
|
|||||||
("data-always-visible", "true" if always_visible else "false"),
|
("data-always-visible", "true" if always_visible else "false"),
|
||||||
("data-items-visible", str(items_visible)),
|
("data-items-visible", str(items_visible)),
|
||||||
("data-items-scroll", str(items_scroll)),
|
("data-items-scroll", str(items_scroll)),
|
||||||
|
("data-prefetch", str(prefetch)),
|
||||||
("data-sync-url", "true" if sync_url else "false"),
|
("data-sync-url", "true" if sync_url else "false"),
|
||||||
("class", _CONTAINER_CLASS),
|
("class", _CONTAINER_CLASS),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -45,10 +45,13 @@
|
|||||||
var multi = container.getAttribute("data-multi") === "true";
|
var multi = container.getAttribute("data-multi") === "true";
|
||||||
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||||
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
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 syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||||
|
|
||||||
var noResults = options.querySelector("[data-ss-no-results]");
|
var noResults = options.querySelector("[data-ss-no-results]");
|
||||||
var debounceTimer = null;
|
var debounceTimer = null;
|
||||||
|
var pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||||
|
var hasPrefetched = false;
|
||||||
|
|
||||||
function showPanel() {
|
function showPanel() {
|
||||||
options.classList.remove("hidden");
|
options.classList.remove("hidden");
|
||||||
@@ -63,13 +66,12 @@
|
|||||||
|
|
||||||
// ── Render server-fetched rows into the panel ──
|
// ── Render server-fetched rows into the panel ──
|
||||||
function renderRows(items) {
|
function renderRows(items) {
|
||||||
options.querySelectorAll("[data-ss-option]").forEach(function (r) {
|
options.querySelectorAll("[data-ss-option]").forEach(function (row) {
|
||||||
r.remove();
|
row.remove();
|
||||||
});
|
});
|
||||||
items.slice(0, itemsScroll).forEach(function (item) {
|
items.slice(0, itemsScroll).forEach(function (item) {
|
||||||
options.insertBefore(buildRow(item), noResults || null);
|
options.insertBefore(buildRow(item), noResults || null);
|
||||||
});
|
});
|
||||||
setNoResults(items.length === 0);
|
|
||||||
showPanel();
|
showPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,38 +90,61 @@
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Client-side filter of pre-rendered rows ──
|
// ── Client-side filter of the currently loaded rows. Returns the number of
|
||||||
function filterRows(q) {
|
// visible rows so the caller decides whether to show the no-results node. ──
|
||||||
var lower = q.toLowerCase();
|
function filterRows(query) {
|
||||||
var anyVisible = false;
|
var lower = query.toLowerCase();
|
||||||
|
var visibleCount = 0;
|
||||||
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
|
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
|
||||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||||
var match = label.indexOf(lower) !== -1;
|
var match = label.indexOf(lower) !== -1;
|
||||||
item.style.display = match ? "" : "none";
|
item.style.display = match ? "" : "none";
|
||||||
if (match) anyVisible = true;
|
if (match) visibleCount += 1;
|
||||||
});
|
});
|
||||||
setNoResults(!anyVisible);
|
return visibleCount;
|
||||||
showPanel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runSearch() {
|
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||||
var q = search.value.trim();
|
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||||
if (searchUrl && q) {
|
function fetchFromServer(query) {
|
||||||
clearTimeout(debounceTimer);
|
if (pendingRequest) pendingRequest.abort();
|
||||||
debounceTimer = setTimeout(function () {
|
pendingRequest = new AbortController();
|
||||||
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
|
var url = searchUrl + "?q=" + encodeURIComponent(query);
|
||||||
credentials: "same-origin",
|
if (prefetch && !query) url += "&limit=" + prefetch;
|
||||||
|
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||||
|
.then(function (response) {
|
||||||
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function (r) {
|
.then(function (items) {
|
||||||
return r.json();
|
pendingRequest = null;
|
||||||
|
renderRows(items);
|
||||||
|
// Re-apply the live query: the box may hold more text than was sent.
|
||||||
|
setNoResults(filterRows(search.value.trim()) === 0);
|
||||||
})
|
})
|
||||||
.then(renderRows)
|
.catch(function (error) {
|
||||||
.catch(function () {
|
if (error && error.name === "AbortError") return; // superseded
|
||||||
|
pendingRequest = null;
|
||||||
setNoResults(true);
|
setNoResults(true);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called on every keystroke. With a search_url, filter the loaded window
|
||||||
|
// instantly (zero latency) and debounce a server request for the rest;
|
||||||
|
// no-results stays hidden until the response decides it, to avoid a flash
|
||||||
|
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||||
|
// so the client-side filter is authoritative.
|
||||||
|
function runSearch() {
|
||||||
|
var query = search.value.trim();
|
||||||
|
showPanel();
|
||||||
|
if (searchUrl) {
|
||||||
|
filterRows(query);
|
||||||
|
setNoResults(false);
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(function () {
|
||||||
|
fetchFromServer(query);
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
} else {
|
} else {
|
||||||
filterRows(q);
|
setNoResults(filterRows(query) === 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +158,20 @@
|
|||||||
search.value = "";
|
search.value = "";
|
||||||
container._ssDirty = false;
|
container._ssDirty = false;
|
||||||
}
|
}
|
||||||
runSearch();
|
showPanel();
|
||||||
|
if (searchUrl) {
|
||||||
|
if (prefetch && !hasPrefetched) {
|
||||||
|
// Seed the window immediately on first open (not debounced).
|
||||||
|
hasPrefetched = true;
|
||||||
|
fetchFromServer("");
|
||||||
|
} else {
|
||||||
|
// Show whatever is already loaded; the server decides no-results.
|
||||||
|
filterRows(search.value.trim());
|
||||||
|
setNoResults(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNoResults(filterRows(search.value.trim()) === 0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
search.addEventListener("input", function () {
|
search.addEventListener("input", function () {
|
||||||
if (!multi) container._ssDirty = true;
|
if (!multi) container._ssDirty = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user