Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
65 changes: 65 additions & 0 deletions cab/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,68 @@ def test_tags(self):
request.htmx = True
response = top_tags(request)
self.assertEqual(response.status_code, 200)

class SnippetListSearchTestCase(BaseCabTestCase):
"""Test HTMX search functionality on snippet list."""

def test_normal_request_renders_full_page(self):
"""Standard requests should render full page template."""
response = self.client.get(reverse("cab_snippet_list"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "cab/snippet_list.html")
self.assertContains(response, "<html")

def test_htmx_request_returns_partial(self):
"""HTMX requests should return only the partial template."""
response = self.client.get(
reverse("cab_snippet_list"),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "cab/partials/_snippet_table.html")
self.assertNotContains(response, "<html")
self.assertNotContains(response, "<head")

def test_search_filters_results(self):
"""Search query should filter snippets correctly."""
response = self.client.get(
reverse("cab_snippet_list"),
{"q": "Hello"},
)
self.assertEqual(response.status_code, 200)
# Should find snippet1 with "Hello world" in title
self.assertIn(self.snippet1, response.context["object_list"])
# Should not find snippet3 with SQL in title
self.assertNotIn(self.snippet3, response.context["object_list"])

def test_search_minimum_length(self):
"""Search with less than minimum length should return all snippets."""
response = self.client.get(
reverse("cab_snippet_list"),
{"q": "a"}, # Single character, below MIN_QUERY_LENGTH
)
self.assertEqual(response.status_code, 200)
# Should return all snippets (no filtering applied)
self.assertEqual(len(response.context["object_list"]), 3)

def test_search_no_results(self):
"""Search with no matches should return empty list."""
response = self.client.get(
reverse("cab_snippet_list"),
{"q": "nonexistent"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["object_list"]), 0)
Comment thread
Theminacious marked this conversation as resolved.
Outdated

def test_htmx_search_with_query(self):
"""HTMX search request should return filtered partial."""
response = self.client.get(
reverse("cab_snippet_list"),
{"q": "world"},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "cab/partials/_snippet_table.html")
# Should include snippets with "world" in title
self.assertIn(self.snippet1, response.context["object_list"])
self.assertIn(self.snippet2, response.context["object_list"])
32 changes: 30 additions & 2 deletions cab/views/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib.postgres.search import SearchVector
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from django.core.mail import mail_admins
from django.db.models import Count, Q
from django.db.utils import NotSupportedError, ProgrammingError
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
Expand All @@ -16,13 +17,40 @@
from cab.utils import month_object_list, object_detail

# Constants
MIN_QUERY_LENGTH = 2
MIN_QUERY_LENGTH = 3
Comment thread
Theminacious marked this conversation as resolved.
Outdated


def snippet_list(request, queryset=None, **kwargs):
if queryset is None:
queryset = Snippet.objects.active_snippet()

# Handle search query
q = request.GET.get("q", "").strip()
if q and len(q) >= MIN_QUERY_LENGTH:
Comment thread
Theminacious marked this conversation as resolved.
Outdated
# Try PostgreSQL full-text search with ranking
try:
search_vector = SearchVector("title", "description", "author__username")
search_query = SearchQuery(q)
queryset = queryset.annotate(
search=search_vector,
rank=SearchRank(search_vector, search_query)
).filter(search=search_query).order_by("-rank")
except (NotSupportedError, ProgrammingError):
Comment thread
Theminacious marked this conversation as resolved.
Outdated
# Fallback to simple case-insensitive search if PostgreSQL FTS unavailable
queryset = queryset.filter(
Q(title__icontains=q) |
Q(description__icontains=q) |
Q(author__username__icontains=q)
)

Comment thread
Theminacious marked this conversation as resolved.
Outdated
# Pass query to template context
if "extra_context" not in kwargs:
kwargs["extra_context"] = {}
kwargs["extra_context"]["query"] = q

if request.htmx:
kwargs["template_name"] = "cab/partials/_snippet_table.html"

return month_object_list(request, queryset=queryset, paginate_by=20, **kwargs)
Comment thread
Theminacious marked this conversation as resolved.


Expand Down
17 changes: 17 additions & 0 deletions djangosnippets/templates/cab/partials/_snippet_table.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% load core_tags %}

<div id="snippet-table">
{% if object_list %}

{% component 'snippet_list' snippet_list=object_list / %}

{% component 'pagination' pagination_obj=pagination / %}

{% else %}

<div class="text-center py-12">
<h3 class="text-xl font-medium text-gray-500">No snippets found</h3>
</div>

{% endif %}
</div>
69 changes: 44 additions & 25 deletions djangosnippets/templates/cab/snippet_list.html
Original file line number Diff line number Diff line change
@@ -1,36 +1,55 @@
{% extends "base.html" %}
{% load core_tags %}
{% load static %}

{% block bodyclass %}snippet-list{% endblock %}
{% block head_title %}Snippet list{% endblock %}

{% block new_content_header %}
<header class="bg-transparent">
<h1 class="my-20 text-center float-none font-header text-8xl md:text-left">{{ hits }} snippet{{ hits|pluralize }}</h1>
<div class="my-4 flex justify-between px-4">
<div></div>
{% component 'sorting_tabs' object_list=object_list / %}
</div>
</header>
<header class="bg-transparent">

<h1 class="my-20 text-center float-none font-header text-8xl md:text-left">
{{ hits }} snippet{{ hits|pluralize }}
</h1>

<div class="my-4 flex flex-col md:flex-row gap-4 justify-between items-center px-4">

<form
method="get"
action="{{ request.path }}"
hx-get="{{ request.path }}"
hx-target="#snippet-table"
hx-swap="outerHTML"
hx-trigger="input changed delay:500ms from:input[name='q'], search"
hx-push-url="true"
class="w-full md:w-auto"
>
Comment thread
Theminacious marked this conversation as resolved.
Comment thread
Theminacious marked this conversation as resolved.
<label for="search-input" class="sr-only">Search snippets</label>
<input
id="search-input"
type="search"
name="q"
value="{{ query|default:'' }}"
placeholder="Search snippets..."
minlength="3"
class="w-full md:w-96 px-4 py-2 border-2 border-gray-300 rounded-lg focus:border-green-500 focus:ring-0"
>
Comment thread
Theminacious marked this conversation as resolved.
Comment thread
Theminacious marked this conversation as resolved.
{% for key, value in request.GET.items %}
{% if key != 'q' and key != 'page' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
</form>

{% component 'sorting_tabs' object_list=object_list / %}

</div>

</header>
{% endblock %}

{% block content %}
{% if object_list %}
{% component 'snippet_list' snippet_list=object_list / %}
{% component 'pagination' pagination_obj=pagination / %}
{% endif %}
{% endblock %}

{% include "cab/partials/_snippet_table.html" %}

{#{% block sidebar %}#}
{# <nav class="filter">#}
{# <h3>Filter by date</h3>#}
{# <ul>#}
{# <li{% if not months %} class="active"{% endif %}><a href="{{ request.path }}">Any time</a></li>#}
{# <li{% if months == 3 %} class="active"{% endif %}><a href="{{ request.path }}?months=3">3 months</a></li>#}
{# <li{% if months == 6 %} class="active"{% endif %}><a href="{{ request.path }}?months=6">6 months</a></li>#}
{# <li{% if months == 12 %} class="active"{% endif %}><a href="{{ request.path }}?months=12">1 year</a></li>#}
{# </ul>#}
{# </nav>#}
{##}
{# <p><a rel="alternate" href="{% url 'cab_feed_latest' %}" type="application/atom+xml"><i class="fa fa-fw fa-rss-square"></i>Feed of latest snippets</a></p>#}
{#{% endblock %}#}
{% endblock %}
Loading