diff --git a/docs/fields/autocomplete.md b/docs/fields/autocomplete.md new file mode 100644 index 000000000..d9803fc18 --- /dev/null +++ b/docs/fields/autocomplete.md @@ -0,0 +1,68 @@ +--- +title: Autocomplete fields +order: 0 +description: Guide to adding efficient, AJAX-powered autocomplete fields to Django admin with Unfold. +--- + +# Autocomplete fields + +To add autocomplete functionality to `ModelChoiceField` and `ModelMultipleChoiceField`, use `UnfoldAdminAutocompleteModelChoiceField` and `UnfoldAdminMultipleAutocompleteModelChoiceField`. + +**Steps to create autocomplete fields** + +- Start by creating a custom view that subclasses `BaseAutocompleteView` and returns a JSON response containing the available select options. Be sure to perform all necessary permission checks within this view to control user access to the data. +- Next, register each custom view as a URL in your `ModelAdmin` class using the `custom_urls` attribute. +- Finally, in your custom form fields, set the `url_path` to the URL pattern name that points to your custom autocomplete view. + +```python +from django import forms +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from unfold.admin import ModelAdmin +from unfold.views import BaseAutocompleteView +from unfold.fields import ( + UnfoldAdminAutocompleteModelChoiceField, + UnfoldAdminMultipleAutocompleteModelChoiceField, +) + +from .models import MyModel + +# Custom ListView returning JSON with available select options +class MyAutocompleteView(BaseAutocompleteView): + model = MyModel + + def dispatch(self, request, *args, **kwargs): + # DO THE PERMISSIONS CHECKS HERE + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self): + # ADDITIONAL FILTERS AND PERMISSIONS CHECKS HERE + return super().get_queryset() + + +@admin.register(MyModel) +class MyModelAdmin(ModelAdmin): + # Register custom ListView above + custom_urls = ( + ( + "autocomplete-url-path", + "custom_autocomplete_path_name", + MyAutocompleteView.as_view() + ), + ) + +class MyForm(forms.Form): + one_object = UnfoldAdminAutocompleteModelChoiceField( + label=_("Object - Single value"), + # Important for validation. Make sure it offers same results as the custom view + queryset=MyModel.objects.all(), + # Map autocomplete results to the custom view + url_path="admin:custom_autocomplete_path_name", + ) + multiple_objects = UnfoldAdminMultipleAutocompleteModelChoiceField( + label=_("Objects - Multiple values"), + queryset=MyModel.objects.all(), + url_path="admin:custon_autocomplete_path_name", + ) +``` diff --git a/src/unfold/fields.py b/src/unfold/fields.py index 784bd2e25..5b032daef 100644 --- a/src/unfold/fields.py +++ b/src/unfold/fields.py @@ -12,9 +12,10 @@ ManyToManyRel, OneToOneField, ) +from django.forms import ModelChoiceField, ModelMultipleChoiceField, Widget from django.forms.utils import flatatt from django.template.defaultfilters import linebreaksbr -from django.urls import NoReverseMatch, reverse +from django.urls import NoReverseMatch, reverse, reverse_lazy from django.utils.html import conditional_escape, format_html from django.utils.module_loading import import_string from django.utils.safestring import SafeText, mark_safe @@ -22,7 +23,13 @@ from unfold.settings import get_config from unfold.utils import display_for_field, prettify_json -from unfold.widgets import CHECKBOX_LABEL_CLASSES, INPUT_CLASSES, LABEL_CLASSES +from unfold.widgets import ( + CHECKBOX_LABEL_CLASSES, + INPUT_CLASSES, + LABEL_CLASSES, + UnfoldAdminAutocompleteWidget, + UnfoldAdminMultipleAutocompleteWidget, +) class UnfoldAdminReadonlyField(helpers.AdminReadonlyField): @@ -193,7 +200,7 @@ def _resolve_field(self) -> bool | list: class UnfoldAdminField(helpers.AdminField): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) try: @@ -233,3 +240,24 @@ def label_tag(self) -> SafeText: attrs=attrs, label_suffix=required if self.field.field.required else "", ) + + +class AutocompleteFieldMixin: + def __init__(self, url_path: str, *args: Any, **kwargs: Any) -> None: + self.url_path = url_path + super().__init__(*args, **kwargs) + + def widget_attrs(self, widget: Widget) -> dict[str, Any]: + return { + "data-ajax--url": reverse_lazy(self.url_path), + } + + +class UnfoldAdminAutocompleteModelChoiceField(AutocompleteFieldMixin, ModelChoiceField): + widget = UnfoldAdminAutocompleteWidget + + +class UnfoldAdminMultipleAutocompleteModelChoiceField( + AutocompleteFieldMixin, ModelMultipleChoiceField +): + widget = UnfoldAdminMultipleAutocompleteWidget diff --git a/src/unfold/templates/unfold/widgets/select_option_autocomplete.html b/src/unfold/templates/unfold/widgets/select_option_autocomplete.html new file mode 100644 index 000000000..2214032aa --- /dev/null +++ b/src/unfold/templates/unfold/widgets/select_option_autocomplete.html @@ -0,0 +1,5 @@ +{% for name, value in widget.attrs.items %} + {% if name == "selected" %} + + {% endif %} +{% endfor %} diff --git a/src/unfold/views.py b/src/unfold/views.py index 7f0678047..267350d14 100644 --- a/src/unfold/views.py +++ b/src/unfold/views.py @@ -5,7 +5,8 @@ from django.contrib.admin.views.main import ERROR_FLAG, PAGE_VAR from django.contrib.admin.views.main import ChangeList as BaseChangeList from django.contrib.auth.mixins import PermissionRequiredMixin -from django.http import HttpRequest +from django.http import HttpRequest, JsonResponse +from django.views.generic import ListView from unfold.exceptions import UnfoldException from unfold.forms import DatasetChangeListSearchForm @@ -79,3 +80,26 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: "model_admin": self.model_admin, }, ) + + +class BaseAutocompleteView(ListView): + paginate_by = 20 + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse: + super().get(request, *args, **kwargs) + context = self.get_context_data() + + return JsonResponse( + { + "results": [ + { + "id": obj.pk, + "text": str(obj), + } + for obj in self.object_list + ], + "pagination": { + "more": context["page_obj"].has_next(), + }, + } + ) diff --git a/src/unfold/widgets.py b/src/unfold/widgets.py index 9d6895abb..a74dd9e42 100644 --- a/src/unfold/widgets.py +++ b/src/unfold/widgets.py @@ -1,3 +1,4 @@ +import json from collections.abc import Callable from typing import Any @@ -906,3 +907,30 @@ def __init__(self, attrs=None, render_value=False): }, render_value, ) + + +class AutocompleteWidgetMixin: + def __init__(self, attrs: dict | None = None, choices: tuple = ()) -> None: + if not attrs: + attrs = {} + + attrs.update( + { + "data-ajax--cache": "true", + "data-ajax--delay": 250, + "data-ajax--type": "GET", + "data-theme": "admin-autocomplete", + "data-allow-clear": json.dumps(not self.is_required), + "data-placeholder": "", + "class": "unfold-admin-autocomplete admin-autocomplete", + } + ) + super().__init__(attrs, choices) + + +class UnfoldAdminAutocompleteWidget(AutocompleteWidgetMixin, Select): + option_template_name = "unfold/widgets/select_option_autocomplete.html" + + +class UnfoldAdminMultipleAutocompleteWidget(AutocompleteWidgetMixin, SelectMultiple): + option_template_name = "unfold/widgets/select_option_autocomplete.html"