Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration/command.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ UNFOLD = {
}
```

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.

## Custom search callback

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.
Expand Down
10 changes: 8 additions & 2 deletions src/unfold/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from django.contrib.admin import AdminSite
from django.core.cache import cache
from django.core.paginator import Paginator
from django.core.validators import EMPTY_VALUES
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
Expand Down Expand Up @@ -255,7 +256,8 @@ def search(
) -> TemplateResponse:
start_time = time.time()

CACHE_TIMEOUT = 10
CACHE_TIMEOUT = 5 * 60
PER_PAGE = 100

search_term = request.GET.get("s")
extended_search = "extended" in request.GET
Expand Down Expand Up @@ -294,11 +296,15 @@ def search(

execution_time = time.time() - start_time

paginator = Paginator(results, PER_PAGE)

return TemplateResponse(
request,
template=template_name,
context={
"results": results,
"page_obj": paginator,
"results": paginator.page(request.GET.get("page", 1)),
"page_counter": (int(request.GET.get("page", 1)) - 1) * PER_PAGE,
"execution_time": execution_time,
"command_show_history": self._get_config("COMMAND", request).get(
"show_history"
Expand Down
2 changes: 1 addition & 1 deletion src/unfold/static/unfold/css/styles.css

Large diffs are not rendered by default.

28 changes: 20 additions & 8 deletions src/unfold/static/unfold/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function searchDropdown() {
}
},
prevItem() {
if (this.currentIndex > 0) {
if (this.currentIndex > 1) {
this.currentIndex--;
}
},
Expand All @@ -93,6 +93,7 @@ function searchCommand() {
hasResults: false,
openCommandResults: false,
currentIndex: 0,
totalItems: 0,
commandHistory: JSON.parse(localStorage.getItem("commandHistory") || "[]"),
handleOpen() {
this.openCommandResults = true;
Expand All @@ -102,6 +103,7 @@ function searchCommand() {
}, 20);

this.items = document.querySelectorAll("#command-history li");
this.totalItems = this.items.length;
},
handleShortcut(event) {
if (
Expand All @@ -121,25 +123,35 @@ function searchCommand() {
this.openCommandResults = false;
this.el.innerHTML = "";
this.items = undefined;
this.totalItems = 0;
this.currentIndex = 0;
} else {
this.$refs.searchInputCommand.value = "";
}
},
handleContentLoaded(event) {
if (event.target.id !== "command-results") {
if (
event.target.id !== "command-results" &&
event.target.id !== "command-results-list"
) {
return;
}

this.items = event.target.querySelectorAll("li");
this.currentIndex = 0;
this.hasResults = this.items.length > 0;
this.items = document
.getElementById("command-results-list")
.querySelectorAll("li");
this.totalItems = this.items.length;

if (event.target.id === "command-results") {
this.currentIndex = 0;
this.totalItems = this.items.length;
}

this.hasResults = this.totalItems > 0;

if (!this.hasResults) {
this.items = document.querySelectorAll("#command-history li");
}

new SimpleBar(event.target);
},
handleOutsideClick() {
this.$refs.searchInputCommand.value = "";
Expand All @@ -162,7 +174,7 @@ function searchCommand() {
}
},
nextItem() {
if (this.currentIndex < this.items.length) {
if (this.currentIndex < this.totalItems) {
this.currentIndex++;
this.scrollToActiveItem();
}
Expand Down
2 changes: 1 addition & 1 deletion src/unfold/templates/unfold/helpers/command.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
x-on:keydown.escape.prevent="handleEscape()"
x-on:keydown.arrow-down.prevent="nextItem()"
x-on:keydown.arrow-up.prevent="prevItem()"
x-on:keydown.enter.prevent="selectItem()"
x-on:keydown.enter.prevent="selectItem({% if command_show_history %}true{% else %}false{% endif %})"
hx-get="{% url "admin:search" %}?extended=1"
hx-trigger="keyup changed delay:500ms"
hx-select="#command-results-list"
Expand Down
39 changes: 29 additions & 10 deletions src/unfold/templates/unfold/helpers/command_results.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
{% load i18n %}
{% load i18n unfold %}

{% if results %}
<ul id="command-results-list" class="flex flex-col gap-1.5 p-4">
{% for item in results %}
<li class="group"
x-bind:class="{'active': currentIndex === {{ forloop.counter }}}"
x-on:mouseenter="currentIndex = {{ forloop.counter }}">
{% if forloop.last and results.next_page_number %}
hx-get="{% url "admin:search" %}{% unfold_querystring extended=1 page=results.next_page_number %}"
hx-trigger="intersect once threshold:0.5"
hx-swap="beforeend"
hx-select="#command-results-list > *"
hx-target="#command-results-list"
hx-indicator="#command-results-loading"
{% endif %}
x-bind:class="{'active': currentIndex === {{ page_counter|add:forloop.counter }}}"
x-on:mouseenter="currentIndex = {{ page_counter|add:forloop.counter }}">
<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"
href="{{ item.link }}"
data-title="{{ item.title }}"
Expand All @@ -31,20 +39,31 @@
{% endfor %}
</ul>
{% else %}
<ul id="command-results-list" class="px-4 py-8 flex items-center justify-center">
<ul class="px-4 py-8 flex items-center justify-center">
<li class="text-lg">
{% trans "No results matching your query" %}
</li>
</ul>
{% endif %}


<div id="command-results-note" x-show="hasResults">
<div class="border-t border-base-200 px-4 py-3 flex items-center justify-center text-xs dark:border-base-700">
{% blocktranslate count counter=results|length with time=execution_time|floatformat:2 %}
Found {{ counter }} result in {{ time }} seconds
{% plural %}
Found {{ counter }} results in {{ time }} seconds
{% endblocktranslate %}
<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">
<span class="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>

<span>
{% trans "Loading more results..." %}
</span>
</div>

<div>
{% blocktranslate count counter=page_obj.count with time=execution_time|floatformat:2 %}
Found {{ counter }} result in {{ time }} seconds
{% plural %}
Found {{ counter }} results in {{ time }} seconds
{% endblocktranslate %}
</div>
</div>
</div>
1 change: 1 addition & 0 deletions src/unfold/templates/unfold/helpers/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
name="s"
x-ref="searchInput"
x-on:focus="openSearchResults = true; currentIndex = 0"
x-on:keydown="openSearchResults = true;"
x-on:keydown.arrow-down.prevent="nextItem()"
x-on:keydown.arrow-up.prevent="prevItem()"
x-on:keydown.escape.prevent="openSearchResults = false; if ($refs.searchInput.value === '') { $refs.searchInput.blur() } else { $refs.searchInput.value = '' }"
Expand Down
33 changes: 32 additions & 1 deletion src/unfold/templatetags/unfold.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from typing import Any, Optional, Union

from django import template
Expand Down Expand Up @@ -607,6 +607,37 @@ def querystring_params(
return result.urlencode()


@register.simple_tag(name="unfold_querystring", takes_context=True)
def unfold_querystring(context, *args, **kwargs):
"""
Duplicated querystring template tag from Django core to allow
it using in Django 4.x. Once 4.x is not supported, remove it.
"""
if not args:
args = [context.request.GET]
params = QueryDict(mutable=True)
for d in [*args, kwargs]:
if not isinstance(d, Mapping):
raise TemplateSyntaxError(
"querystring requires mappings for positional arguments (got "
f"{d!r} instead)."
)
for key, value in d.items():
if not isinstance(key, str):
raise TemplateSyntaxError(
f"querystring requires strings for mapping keys (got {key!r} "
"instead)."
)
if value is None:
params.pop(key, None)
elif isinstance(value, Iterable) and not isinstance(value, str):
params.setlist(key, value)
else:
params[key] = value
query_string = params.urlencode() if params else ""
return f"?{query_string}"


@register.simple_tag(takes_context=True)
def header_title(context: RequestContext) -> str:
parts = []
Expand Down
2 changes: 1 addition & 1 deletion tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def test_command_search_non_existing_record(admin_client):
)
@pytest.mark.django_db
def test_command_search_extended_models(admin_client, tag_factory):
tag_factory(name="test-tagasdfsadf")
tag_factory(name="test-tag")
response = admin_client.get(reverse("admin:search") + "?s=test-tag&extended=1")

assert response.status_code == HTTPStatus.OK
Expand Down