Skip to content

Commit 7fe448b

Browse files
authored
feat: command infinite scrolling (#1550)
1 parent bca724b commit 7fe448b

File tree

9 files changed

+95
-24
lines changed

9 files changed

+95
-24
lines changed

docs/configuration/command.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ UNFOLD = {
2626
}
2727
```
2828

29+
Command results use infinite scrolling with a default page size of 100 results. When the last item becomes visible in the viewport, a new page of results is automatically loaded and appended to the existing list, allowing continuous browsing through search results.
30+
2931
## Custom search callback
3032

3133
The search callback feature provides a way to define a custom hook that can inject additional content into search results. This is particularly useful when you want to search for results from external sources or services beyond the Django admin interface.

src/unfold/sites.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from django.contrib.admin import AdminSite
88
from django.core.cache import cache
9+
from django.core.paginator import Paginator
910
from django.core.validators import EMPTY_VALUES
1011
from django.http import HttpRequest, HttpResponse
1112
from django.template.response import TemplateResponse
@@ -255,7 +256,8 @@ def search(
255256
) -> TemplateResponse:
256257
start_time = time.time()
257258

258-
CACHE_TIMEOUT = 10
259+
CACHE_TIMEOUT = 5 * 60
260+
PER_PAGE = 100
259261

260262
search_term = request.GET.get("s")
261263
extended_search = "extended" in request.GET
@@ -294,11 +296,15 @@ def search(
294296

295297
execution_time = time.time() - start_time
296298

299+
paginator = Paginator(results, PER_PAGE)
300+
297301
return TemplateResponse(
298302
request,
299303
template=template_name,
300304
context={
301-
"results": results,
305+
"page_obj": paginator,
306+
"results": paginator.page(request.GET.get("page", 1)),
307+
"page_counter": (int(request.GET.get("page", 1)) - 1) * PER_PAGE,
302308
"execution_time": execution_time,
303309
"command_show_history": self._get_config("COMMAND", request).get(
304310
"show_history"

src/unfold/static/unfold/css/styles.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/unfold/static/unfold/js/app.js

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function searchDropdown() {
6868
}
6969
},
7070
prevItem() {
71-
if (this.currentIndex > 0) {
71+
if (this.currentIndex > 1) {
7272
this.currentIndex--;
7373
}
7474
},
@@ -93,6 +93,7 @@ function searchCommand() {
9393
hasResults: false,
9494
openCommandResults: false,
9595
currentIndex: 0,
96+
totalItems: 0,
9697
commandHistory: JSON.parse(localStorage.getItem("commandHistory") || "[]"),
9798
handleOpen() {
9899
this.openCommandResults = true;
@@ -102,6 +103,7 @@ function searchCommand() {
102103
}, 20);
103104

104105
this.items = document.querySelectorAll("#command-history li");
106+
this.totalItems = this.items.length;
105107
},
106108
handleShortcut(event) {
107109
if (
@@ -121,25 +123,35 @@ function searchCommand() {
121123
this.openCommandResults = false;
122124
this.el.innerHTML = "";
123125
this.items = undefined;
126+
this.totalItems = 0;
124127
this.currentIndex = 0;
125128
} else {
126129
this.$refs.searchInputCommand.value = "";
127130
}
128131
},
129132
handleContentLoaded(event) {
130-
if (event.target.id !== "command-results") {
133+
if (
134+
event.target.id !== "command-results" &&
135+
event.target.id !== "command-results-list"
136+
) {
131137
return;
132138
}
133139

134-
this.items = event.target.querySelectorAll("li");
135-
this.currentIndex = 0;
136-
this.hasResults = this.items.length > 0;
140+
this.items = document
141+
.getElementById("command-results-list")
142+
.querySelectorAll("li");
143+
this.totalItems = this.items.length;
144+
145+
if (event.target.id === "command-results") {
146+
this.currentIndex = 0;
147+
this.totalItems = this.items.length;
148+
}
149+
150+
this.hasResults = this.totalItems > 0;
137151

138152
if (!this.hasResults) {
139153
this.items = document.querySelectorAll("#command-history li");
140154
}
141-
142-
new SimpleBar(event.target);
143155
},
144156
handleOutsideClick() {
145157
this.$refs.searchInputCommand.value = "";
@@ -162,7 +174,7 @@ function searchCommand() {
162174
}
163175
},
164176
nextItem() {
165-
if (this.currentIndex < this.items.length) {
177+
if (this.currentIndex < this.totalItems) {
166178
this.currentIndex++;
167179
this.scrollToActiveItem();
168180
}

src/unfold/templates/unfold/helpers/command.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
x-on:keydown.escape.prevent="handleEscape()"
3131
x-on:keydown.arrow-down.prevent="nextItem()"
3232
x-on:keydown.arrow-up.prevent="prevItem()"
33-
x-on:keydown.enter.prevent="selectItem()"
33+
x-on:keydown.enter.prevent="selectItem({% if command_show_history %}true{% else %}false{% endif %})"
3434
hx-get="{% url "admin:search" %}?extended=1"
3535
hx-trigger="keyup changed delay:500ms"
3636
hx-select="#command-results-list"

src/unfold/templates/unfold/helpers/command_results.html

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
{% load i18n %}
1+
{% load i18n unfold %}
22

33
{% if results %}
44
<ul id="command-results-list" class="flex flex-col gap-1.5 p-4">
55
{% for item in results %}
66
<li class="group"
7-
x-bind:class="{'active': currentIndex === {{ forloop.counter }}}"
8-
x-on:mouseenter="currentIndex = {{ forloop.counter }}">
7+
{% if forloop.last and results.next_page_number %}
8+
hx-get="{% url "admin:search" %}{% unfold_querystring extended=1 page=results.next_page_number %}"
9+
hx-trigger="intersect once threshold:0.5"
10+
hx-swap="beforeend"
11+
hx-select="#command-results-list > *"
12+
hx-target="#command-results-list"
13+
hx-indicator="#command-results-loading"
14+
{% endif %}
15+
x-bind:class="{'active': currentIndex === {{ page_counter|add:forloop.counter }}}"
16+
x-on:mouseenter="currentIndex = {{ page_counter|add:forloop.counter }}">
917
<a class="bg-base-100 flex items-center rounded-default px-3.5 py-3 group-[.active]:bg-primary-600 group-[.active]:text-white dark:bg-white/[.04] dark:text-base-200 dark:group-[.active]:bg-primary-600 dark:group-[.active]:text-white"
1018
href="{{ item.link }}"
1119
data-title="{{ item.title }}"
@@ -31,20 +39,31 @@
3139
{% endfor %}
3240
</ul>
3341
{% else %}
34-
<ul id="command-results-list" class="px-4 py-8 flex items-center justify-center">
42+
<ul class="px-4 py-8 flex items-center justify-center">
3543
<li class="text-lg">
3644
{% trans "No results matching your query" %}
3745
</li>
3846
</ul>
3947
{% endif %}
4048

41-
4249
<div id="command-results-note" x-show="hasResults">
4350
<div class="border-t border-base-200 px-4 py-3 flex items-center justify-center text-xs dark:border-base-700">
44-
{% blocktranslate count counter=results|length with time=execution_time|floatformat:2 %}
45-
Found {{ counter }} result in {{ time }} seconds
46-
{% plural %}
47-
Found {{ counter }} results in {{ time }} seconds
48-
{% endblocktranslate %}
51+
<div id="command-results-loading" class="hidden flex-row gap-2 grow h-[16px] items-center justify-center w-[16px] w-full [&.htmx-request]:flex [&.htmx-request+div]:hidden">
52+
<span class="material-symbols-outlined animate-spin text-sm">
53+
progress_activity
54+
</span>
55+
56+
<span>
57+
{% trans "Loading more results..." %}
58+
</span>
59+
</div>
60+
61+
<div>
62+
{% blocktranslate count counter=page_obj.count with time=execution_time|floatformat:2 %}
63+
Found {{ counter }} result in {{ time }} seconds
64+
{% plural %}
65+
Found {{ counter }} results in {{ time }} seconds
66+
{% endblocktranslate %}
67+
</div>
4968
</div>
5069
</div>

src/unfold/templates/unfold/helpers/search.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
name="s"
2828
x-ref="searchInput"
2929
x-on:focus="openSearchResults = true; currentIndex = 0"
30+
x-on:keydown="openSearchResults = true;"
3031
x-on:keydown.arrow-down.prevent="nextItem()"
3132
x-on:keydown.arrow-up.prevent="prevItem()"
3233
x-on:keydown.escape.prevent="openSearchResults = false; if ($refs.searchInput.value === '') { $refs.searchInput.blur() } else { $refs.searchInput.value = '' }"

src/unfold/templatetags/unfold.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import json
2-
from collections.abc import Mapping
2+
from collections.abc import Iterable, Mapping
33
from typing import Any, Optional, Union
44

55
from django import template
@@ -607,6 +607,37 @@ def querystring_params(
607607
return result.urlencode()
608608

609609

610+
@register.simple_tag(name="unfold_querystring", takes_context=True)
611+
def unfold_querystring(context, *args, **kwargs):
612+
"""
613+
Duplicated querystring template tag from Django core to allow
614+
it using in Django 4.x. Once 4.x is not supported, remove it.
615+
"""
616+
if not args:
617+
args = [context.request.GET]
618+
params = QueryDict(mutable=True)
619+
for d in [*args, kwargs]:
620+
if not isinstance(d, Mapping):
621+
raise TemplateSyntaxError(
622+
"querystring requires mappings for positional arguments (got "
623+
f"{d!r} instead)."
624+
)
625+
for key, value in d.items():
626+
if not isinstance(key, str):
627+
raise TemplateSyntaxError(
628+
f"querystring requires strings for mapping keys (got {key!r} "
629+
"instead)."
630+
)
631+
if value is None:
632+
params.pop(key, None)
633+
elif isinstance(value, Iterable) and not isinstance(value, str):
634+
params.setlist(key, value)
635+
else:
636+
params[key] = value
637+
query_string = params.urlencode() if params else ""
638+
return f"?{query_string}"
639+
640+
610641
@register.simple_tag(takes_context=True)
611642
def header_title(context: RequestContext) -> str:
612643
parts = []

tests/test_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_command_search_non_existing_record(admin_client):
5555
)
5656
@pytest.mark.django_db
5757
def test_command_search_extended_models(admin_client, tag_factory):
58-
tag_factory(name="test-tagasdfsadf")
58+
tag_factory(name="test-tag")
5959
response = admin_client.get(reverse("admin:search") + "?s=test-tag&extended=1")
6060

6161
assert response.status_code == HTTPStatus.OK

0 commit comments

Comments
 (0)