Tasos Sangiotis

Simple django search

Sometimes you want to create a search box for your model entries in a django app.

In order to not mess with complex search packages you can use the icontains QuerySet test on a filter.

And because the UI is almost always the same, for my simple needs I have the following starting point.

On your views.py add a general form that contains the form action:

from django.views.generic import TemplateView

class SearchForm(TemplateView):
    """Search form"""

    template_name = "app/search/search_form.html"

Along with the form you specify a place to put the results.

This way you have a portable layout you can drop whenever in the app.


<style>
.d-none {
  display: none;
}
</style>

<div 
  data-controller="search-basemodel"
  data-search-basemodel-url="{% url 'basemodel_search' %}">
  
  <input type="text" 
    data-target="search-basemodel.query"
    data-action="keydown->search-basemodel#fetchWithEnter"
    placeholder="{% trans 'Base model name' %}"
    aria-describedby="button-search">
  <button 
    type="submit" id="button-search"
    data-action="search-basemodel#fetchResults" >
    {% trans "Search" %}
  </button>

  <div>
    <div class="spinner d-none" data-target="search-basemodel.loader" role="status">
        <span class="sr-only">{% trans "Loading..." %}</span>
    </div>

    <div data-target="search-basemodel.results">
    </div>
    
  </div>
</div>

It is accompanied by a small javascript controller that makes a request and returns the results in a div inside the scaffold. I use Stimulus for my JS bits but everything is very simple as you will see.

// search_basemodel_controller.js

import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["query", "results", "loader"];

  disconnect() {
    this.reset();
  }

  fetchWithEnter(event) {
    if (event.keyCode == 13) {
      this.fetchResults();
    }
  }

  fetchResults() {
    if (this.query == "") {
      this.reset();
      return;
    }

    if (this.query == this.previousQuery) {
      return;
    }
    this.previousQuery = this.query;

    const url = this.data.get("url") + this.query;

    this.loaderTarget.classList.remove("d-none");

    this.abortPreviousFetchRequest();

    this.abortController = new AbortController();
    fetch(url, { signal: this.abortController.signal })
      .then((response) => response.text())
      .then((html) => {
        this.resultsTarget.innerHTML = html;
        this.loaderTarget.classList.add("d-none");
      })
      .catch(() => {});
  }

  // private

  reset() {
    this.resultsTarget.innerHTML = "";
    this.queryTarget.value = "";
    this.previousQuery = null;
    this.loaderTarget.classList.add("d-none");
    this.resultsTarget.classList.add("d-none");
  }

  abortPreviousFetchRequest() {
    if (this.abortController) {
      this.abortController.abort();
    }
  }

  get query() {
    return this.queryTarget.value;
  }
}

This adds the query as a kwarg to a request on another view and returns the result inside the initial view.

# views.py

from django.views.generic import ListView

from app.models import BaseModel

class BaseModelSearch(ListView):
    """
    Search entries by name
    """

    template_name = "app/search/search_results.html"

    def get_queryset(self):
        """Return search results."""
        plantlayers = BaseModel.objects.filter(
            name__icontains=self.kwargs.get("query")
        )

        return plantlayers.filter(name__icontains=self.kwargs.get("query"))

# urls.py

urlpatterns = [
    ...,
    path("search", views.SearchForm.as_view(), name="search"),
    path(
        "search/basemodel/",
        views.BaseModelSearch.as_view(),
        name="basemodel_search",
    ),
    path(
        "search/basemodel/<str:query>",
        views.BaseModelSearch.as_view(),
        name="basemodel_search",
    ),
    ...,

The cool thing you can do with this setup is make one request and apply it to multiple models.


<style>
.d-none {
  display: none;
}
</style>

<div 
  data-controller="search-model1 search-model2"
  data-search-model1-url="{% url 'model1_search' %}"
  data-search-model2-url="{% url 'model2_search' %}">
  
  <input type="text" 
    data-target="search-model1.query search-model2.query"
    data-action="keydown->search-model1#fetchWithEnter keydown->search-model2#fetchWithEnter"
    placeholder="{% trans 'Model1 or Model2 name' %}"
    aria-describedby="button-search">
  <button 
    type="submit" id="button-search"
    data-action="search-model1#fetchResults search-model2#fetchResults" >
    {% trans "Search" %}
  </button>

  <div>
    <div class="spinner d-none" data-target="search-model1.loader" role="status">
        <span class="sr-only">{% trans "Loading..." %}</span>
    </div>

    <div data-target="search-model1.results">
    </div>
    
  </div>
  <div>
    <div class="spinner d-none" data-target="search-model2.loader" role="status">
        <span class="sr-only">{% trans "Loading..." %}</span>
    </div>

    <div data-target="search-model2.results">
    </div>
    
  </div>
</div>

This is a starting point which is enough most of the times.

For the form page, each results page and error page you will style a UI to your liking and fix the layout as needed. You can add pagination and other stuff according to your needs.

As is, you need to duplicate the controller for each model you want to search even if the controller code is exactly the same. The last part pains my heart.

You can make it specific to your application though and even use something like Turbo Frames or htmx to make something better.

Comment details and information

To make a comment, please send an e-mail using the button below. Your e-mail address won't be shared and will be deleted from my records after the comment is published. If you don't want your real name to be credited alongside your comment, please specify the name you would like to use. If you would like your name to link to a specific URL, please share that as well. Thank you.