Skip to content

feat: command #1433

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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 .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
name: unfold

services:
unfold:
command: bash -c "python tests/server/manage.py migrate && python tests/server/manage.py runserver 0.0.0.0:8000"
Expand Down
46 changes: 46 additions & 0 deletions docs/configuration/command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: Command
order: 3
description: A guide to using the command in Django Unfold admin interface for quick navigation and search functionality. Configure model search capabilities and customize search fields for enhanced admin experience.
---

The command can be activated by pressing `cmd + K` on Mac or `ctrl + K` on Windows/Linux. By default, the search functionality is limited to application and model names only.

To enable searching through model data, you need to set `UNFOLD["COMMAND"]["search_models"] = True` in your configuration. However, be aware that searching through all models can be a database-intensive operation since it queries across all model data.

For a model to be searchable, you must define the `search_fields` attribute on its admin class. This attribute specifies which fields will be used when searching through the model's data.

```python
UNFOLD = {
# ...
"COMMAND": {
"search_models": True, # Default: False
"search_callback": "utils.search_callback"
},
# ...
}
```

## 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.

When implementing a search callback, keep in mind that you'll need to handle permissions manually to ensure users only see results they have access to.

```python
# utils.py
from unfold.dataclasses import SearchResult


def search_callback(request, search_term):
# Do custom search, e.g. third party service

return [
SearchResult(
"title": "Some title",
"description": "Extra content",
"link": "https://example.com",
"icon": "database",
)
]
```
8 changes: 8 additions & 0 deletions src/unfold/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ class UnfoldAction:
variant: Optional[ActionVariant] = ActionVariant.DEFAULT


@dataclass
class SearchResult:
title: str
description: str
link: str
icon: Optional[str]


@dataclass
class Favicon:
href: Union[str, Callable]
Expand Down
4 changes: 4 additions & 0 deletions src/unfold/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"action": None,
"navigation": [],
},
"COMMAND": {
"search_models": False, # Enable search in the models
"search_callback": None, # Inject a custom callback to the search form
},
"SIDEBAR": {
"show_search": False,
"show_all_applications": False,
Expand Down
117 changes: 100 additions & 17 deletions src/unfold/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
from urllib.parse import parse_qs, urlparse

from django.contrib.admin import AdminSite
from django.core.cache import cache
from django.core.validators import EMPTY_VALUES
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
from django.urls import URLPattern, path, reverse, reverse_lazy
from django.utils.functional import lazy
from django.utils.module_loading import import_string
from django.utils.text import slugify

from unfold.dataclasses import DropdownItem, Favicon
from unfold.dataclasses import DropdownItem, Favicon, SearchResult

try:
from django.contrib.auth.decorators import login_not_required
Expand Down Expand Up @@ -165,26 +167,21 @@ def toggle_sidebar(

return HttpResponse(status=HTTPStatus.OK)

def search(
self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
) -> TemplateResponse:
query = request.GET.get("s").lower()
app_list = super().get_app_list(request)
apps = []
def _search_apps(
self, app_list: list[dict[str, Any]], search_term: str
) -> list[SearchResult]:
results = []

if query in EMPTY_VALUES:
return HttpResponse()
apps = []

for app in app_list:
if query in app["name"].lower():
if search_term in app["name"].lower():
apps.append(app)
continue

models = []

for model in app["models"]:
if query in model["name"].lower():
if search_term in model["name"].lower():
models.append(model)

if len(models) > 0:
Expand All @@ -194,15 +191,101 @@ def search(
for app in apps:
for model in app["models"]:
results.append(
{
"app": app,
"model": model,
}
SearchResult(
title=str(model["name"]),
description=app["name"],
link=model["admin_url"],
icon="tag",
)
)

return results

def _search_models(
self, request: HttpRequest, app_list: list[dict[str, Any]], search_term: str
) -> list[SearchResult]:
results = []

for app in app_list:
for model in app["models"]:
admin_instance = self._registry.get(model["model"])
search_fields = admin_instance.get_search_fields(request)

if not search_fields:
continue

pks = []

qs = admin_instance.get_queryset(request)
search_results, _has_duplicates = admin_instance.get_search_results(
request, qs, search_term
)

for item in search_results:
if item.pk in pks:
continue

pks.append(item.pk)

link = reverse_lazy(
f"{self.name}:{admin_instance.model._meta.app_label}_{admin_instance.model._meta.model_name}_change",
args=(item.pk,),
)

results.append(
SearchResult(
title=str(item),
description=f"{item._meta.app_label.capitalize()} - {item._meta.verbose_name.capitalize()}",
link=link,
icon="data_object",
)
)

return results

def search(
self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
) -> TemplateResponse:
CACHE_TIMEOUT = 10

search_term = request.GET.get("s")
extended_search = "extended" in request.GET
app_list = super().get_app_list(request)
template_name = "unfold/helpers/search_results.html"

if search_term in EMPTY_VALUES:
return HttpResponse()

search_term = search_term.lower()
cache_key = f"unfold_search_{request.user.pk}_{slugify(search_term)}"
cache_results = cache.get(cache_key)

if extended_search:
template_name = "unfold/helpers/search_results_extended.html"

if cache_results:
results = cache_results
else:
results = self._search_apps(app_list, search_term)
search_models = self._get_config("COMMAND", request).get("search_models")
search_callback = self._get_config("COMMAND", request).get(
"search_callback"
)

if extended_search:
if search_callback:
results.extend(
self._get_value(search_callback, request, search_term)
)

if search_models is True:
results.extend(self._search_models(request, app_list, search_term))

cache.set(cache_key, results, timeout=CACHE_TIMEOUT)

return TemplateResponse(
request,
template="unfold/helpers/search_results.html",
template=template_name,
context={
"results": results,
},
Expand Down
2 changes: 1 addition & 1 deletion src/unfold/static/unfold/css/styles.css

Large diffs are not rendered by default.

85 changes: 84 additions & 1 deletion src/unfold/static/unfold/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function searchForm() {
*************************************************************/
function searchDropdown() {
return {
openSearchResults: true,
openSearchResults: false,
currentIndex: 0,
applyShortcut(event) {
if (
Expand Down Expand Up @@ -86,6 +86,89 @@ function searchDropdown() {
};
}

/*************************************************************
* Search command
*************************************************************/
function searchCommand() {
return {
el: document.getElementById("command-results"),
items: undefined,
openSearchResults: false,
currentIndex: 0,
handleShortcut(event) {
if (
event.key === "k" &&
(event.metaKey || event.ctrlKey) &&
document.activeElement.tagName.toLowerCase() !== "input" &&
document.activeElement.tagName.toLowerCase() !== "textarea" &&
!document.activeElement.isContentEditable
) {
event.preventDefault();
this.openSearchResults = true;
this.toggleBodyOverflow();
setTimeout(() => {
this.$refs.searchInputCommand.focus();
}, 20);
}

if (event.key === "Escape" && this.openSearchResults) {
event.preventDefault();
this.openSearchResults = false;
this.el.innerHTML = "";
this.items = undefined;
this.currentIndex = 0;
}
},
handleEscape() {
if (this.$refs.searchInputCommand.value === "") {
this.openSearchResults = false;
this.toggleBodyOverflow();
} else {
this.$refs.searchInputCommand.value = "";
}
},
handleContentLoaded(event) {
this.items = event.target.querySelectorAll("li");
new SimpleBar(event.target);
},
handleOutsideClick() {
this.$refs.searchInputCommand.value = "";
this.openSearchResults = false;
},
toggleBodyOverflow() {
document
.getElementsByTagName("body")[0]
.classList.toggle("overflow-hidden");
},
scrollToActiveItem() {
const item = this.items[this.currentIndex - 1];

if (item) {
item.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
},
nextItem() {
if (this.currentIndex < this.items.length) {
this.currentIndex++;
this.scrollToActiveItem();
}
},
prevItem() {
if (this.currentIndex > 1) {
this.currentIndex--;
this.scrollToActiveItem();
}
},
selectItem() {
window.location =
this.items[this.currentIndex - 1].querySelector("a").href;
},
};
}

/*************************************************************
* Warn without saving
*************************************************************/
Expand Down
4 changes: 2 additions & 2 deletions src/unfold/templates/admin/change_list_results.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@
<td class="align-middle cursor-pointer flex border-b border-base-200 font-normal px-2.5 py-2 relative text-left before:font-semibold before:text-font-important-light before:block before:capitalize before:content-[attr(data-label)] before:mr-auto lg:before:hidden lg:border-b-0 lg:border-t lg:pl-3 lg:pr-0 lg:py-3 lg:table-cell dark:border-base-800 dark:lg:border-base-800 dark:before:text-font-important-dark lg:w-px"
data-label="{% trans "Expand row" %}"
x-on:click="rowOpen = !rowOpen">
<div class="absolute bg-primary-600 -bottom-px hidden left-0 top-0 w-0.5 z-10 lg:block" x-show="rowOpen"></div>
<span class="material-symbols-outlined select-none block! -rotate-90 transition-all" x-bind:class="rowOpen && 'rotate-0'">
<div class="absolute bg-primary-600 -bottom-px hidden left-0 top-0 w-0.5 z-[50] lg:block" x-show="rowOpen"></div>
<span class="material-symbols-outlined select-none block! h-[18px] w-[18px] -rotate-90 transition-all" x-bind:class="rowOpen && 'rotate-0'">
expand_more
</span>
</td>
Expand Down
41 changes: 41 additions & 0 deletions src/unfold/templates/unfold/helpers/command.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{% load i18n %}

{% if request.user.is_staff %}
<div class="backdrop-blur-xs bg-base-900/80 flex flex-col fixed inset-0 p-32 z-50" x-data="searchCommand()" x-on:keydown.window="handleShortcut($event)" x-show="openSearchResults">
<div class="bg-white flex flex-col max-w-3xl min-h-0 mx-auto rounded-default shadow-lg w-full dark:bg-base-800" x-on:click.outside="handleOutsideClick()">
<div class="flex flex-row items-center px-4 w-full">
<button type="submit" id="command-submit" class="group flex items-center focus:outline-hidden">
<span class="block material-symbols-outlined text-xl text-base-400 dark:text-base-500 group-[.htmx-request]:hidden">
search
</span>

<span class="hidden material-symbols-outlined text-xl text-base-400 dark:text-base-500 group-[.htmx-request]:block animate-spin">
progress_activity
</span>
</button>

<input
type="search"
placeholder="{% trans "Type to search" %}"
class="font-medium grow px-2 py-4 placeholder-font-subtle-light text-font-default-light focus:outline-hidden dark:placeholder-font-subtle-dark dark:text-font-default-dark"
name="s"
id="search-input-command"
x-ref="searchInputCommand"
hx-indicator="#command-submit"
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()"
hx-get="{% url "admin:search" %}?extended=1"
hx-trigger="keyup changed delay:500ms"
hx-target="#command-results">

{% include "unfold/helpers/shortcut.html" with shortcut="esc" %}
</div>

<div id="command-results" class="grow h-full overflow-auto w-full dark:border-base-700" data-simplebar x-on:htmx:after-swap="handleContentLoaded($event)" x-bind:class="{'border-t border-base-200 p-4': items !== undefined && items.length > 0}">

</div>
</div>
</div>
{% endif %}
Loading