Skip to content
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
68 changes: 68 additions & 0 deletions cab/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def setUp(self):
author=self.user_a,
description="A greeting\n==========",
code='print "Hello, world"',
version="5.0",
)
self.snippet1.tags.add("hello", "world")

Expand All @@ -53,6 +54,7 @@ def setUp(self):
author=self.user_b,
description="A farewell\n==========",
code='print "Goodbye, world"',
version="5.0",
)
self.snippet2.tags.add("goodbye", "world")

Expand All @@ -62,6 +64,7 @@ def setUp(self):
author=self.user_a,
description="Haxor some1z db",
code="DROP TABLE accounts;",
version="5.0",
)
self.snippet3.tags.add("haxor")

Expand Down Expand Up @@ -613,6 +616,7 @@ def setUp(self):
author=self.user_a,
description="A greeting\n==========",
code='print "Hello, world"',
version="5.0",
)
self.snippet1.tags.add("hello", "world")

Expand All @@ -622,6 +626,7 @@ def setUp(self):
author=self.user_b,
description="A farewell\n==========",
code='print "Goodbye, world"',
version="5.0",
)
self.snippet2.tags.add("goodbye", "world")

Expand All @@ -631,6 +636,7 @@ def setUp(self):
author=self.user_a,
description="Haxor some1z db",
code="DROP TABLE accounts;",
version="5.0",
)
self.snippet3.tags.add("haxor")

Expand Down Expand Up @@ -680,3 +686,65 @@ 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"),
{"q": "django"},
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)
self.assertIn(self.snippet1, response.context["object_list"])
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"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(list(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(list(response.context["object_list"])), 0)

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")
self.assertIn(self.snippet1, response.context["object_list"])
self.assertIn(self.snippet2, response.context["object_list"])
27 changes: 26 additions & 1 deletion cab/views/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
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 import connection
from django.db.models import Count, Q
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
Expand All @@ -17,12 +18,36 @@

# Constants
MIN_QUERY_LENGTH = 2
SNIPPET_SEARCH_MIN_LENGTH = 3


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

q = request.GET.get("q", "").strip()
if q and len(q) > SNIPPET_SEARCH_MIN_LENGTH:
if connection.vendor == "postgresql":
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")
else:
queryset = queryset.filter(
Q(title__icontains=q) |
Q(description__icontains=q) |
Q(author__username__icontains=q)
)

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)


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>
72 changes: 47 additions & 25 deletions djangosnippets/templates/cab/snippet_list.html
Original file line number Diff line number Diff line change
@@ -1,36 +1,58 @@
{% 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">

<div id="snippet-count" hx-swap-oob="true">
<h1 class="my-20 text-center float-none font-header text-8xl md:text-left">
{{ hits }} snippet{{ hits|pluralize }}
</h1>
</div>

<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 delay:500ms, search"
hx-push-url="true"
hx-validate="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 %}