feat: plant list with HTMX live search and indoor/outdoor filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
18e128549d
commit
c7a9699acc
7 changed files with 152 additions and 13 deletions
24
plants/templates/plants/partials/plant_list_results.html
Normal file
24
plants/templates/plants/partials/plant_list_results.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{% for plant in plants %}
|
||||||
|
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3 py-2">
|
||||||
|
{% if plant.photo %}
|
||||||
|
<img src="{{ plant.photo.url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||||||
|
{% elif plant.species and plant.species.api_image_url %}
|
||||||
|
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||||||
|
{% else %}
|
||||||
|
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ plant.location }}
|
||||||
|
· {% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted text-center mt-4">No plants found.</p>
|
||||||
|
{% endfor %}
|
||||||
27
plants/templates/plants/plant_list.html
Normal file
27
plants/templates/plants/plant_list.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% extends "plants/base.html" %}
|
||||||
|
{% block title %}Plants — PlantDB{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value="{{ q }}"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="🔍 Search plants..."
|
||||||
|
hx-get="{% url 'plant_list' %}"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-target="#plant-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group w-100 mb-3" role="group">
|
||||||
|
<a href="{% url 'plant_list' %}" class="btn btn-sm {% if not filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
|
||||||
|
<a href="{% url 'plant_list' %}?filter=indoor" class="btn btn-sm {% if filter == 'indoor' %}btn-success{% else %}btn-outline-success{% endif %}">Indoor</a>
|
||||||
|
<a href="{% url 'plant_list' %}?filter=outdoor" class="btn btn-sm {% if filter == 'outdoor' %}btn-success{% else %}btn-outline-success{% endif %}">Outdoor</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="plant-list">
|
||||||
|
{% include "plants/partials/plant_list_results.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
2
plants/templates/plants/pruning_calendar.html
Normal file
2
plants/templates/plants/pruning_calendar.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% extends "plants/base.html" %}
|
||||||
|
{% block content %}<p>Pruning calendar coming soon.</p>{% endblock %}
|
||||||
|
|
@ -31,3 +31,39 @@ class TestDashboard:
|
||||||
)
|
)
|
||||||
resp = client.get(reverse('dashboard'))
|
resp = client.get(reverse('dashboard'))
|
||||||
assert plant in resp.context['due_this_month']
|
assert plant in resp.context['due_this_month']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestPlantList:
|
||||||
|
def test_returns_200(self, client):
|
||||||
|
resp = client.get(reverse('plant_list'))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_lists_plants(self, client):
|
||||||
|
Plant.objects.create(name='Monstera', location='Living room')
|
||||||
|
resp = client.get(reverse('plant_list'))
|
||||||
|
assert 'Monstera' in resp.content.decode()
|
||||||
|
|
||||||
|
def test_search_filters_by_name(self, client):
|
||||||
|
Plant.objects.create(name='Monstera', location='Living room')
|
||||||
|
Plant.objects.create(name='Rose', location='Garden')
|
||||||
|
resp = client.get(reverse('plant_list') + '?q=Rose')
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert 'Rose' in content
|
||||||
|
assert 'Monstera' not in content
|
||||||
|
|
||||||
|
def test_htmx_search_uses_partial_template(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
reverse('plant_list') + '?q=rose',
|
||||||
|
HTTP_HX_REQUEST='true',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.templates[0].name == 'plants/partials/plant_list_results.html'
|
||||||
|
|
||||||
|
def test_indoor_filter(self, client):
|
||||||
|
Plant.objects.create(name='Fern', location='Office', is_indoor=True)
|
||||||
|
Plant.objects.create(name='Rose', location='Garden', is_indoor=False)
|
||||||
|
resp = client.get(reverse('plant_list') + '?filter=indoor')
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert 'Fern' in content
|
||||||
|
assert 'Rose' not in content
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from plants.views import dashboard
|
from plants.views import dashboard, plants, pruning
|
||||||
|
|
||||||
# Stub views for URLs referenced in templates — replaced in later tasks
|
|
||||||
from django.http import HttpResponse
|
|
||||||
|
|
||||||
|
|
||||||
def _stub(request, *args, **kwargs):
|
|
||||||
return HttpResponse('stub')
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', dashboard.dashboard, name='dashboard'),
|
path('', dashboard.dashboard, name='dashboard'),
|
||||||
path('plants/', _stub, name='plant_list'),
|
path('plants/', plants.plant_list, name='plant_list'),
|
||||||
path('plants/add/', _stub, name='plant_add'),
|
path('plants/add/', plants.plant_add, name='plant_add'),
|
||||||
path('plants/<int:pk>/', _stub, name='plant_detail'),
|
path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'),
|
||||||
path('pruning/', _stub, name='pruning_calendar'),
|
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
|
||||||
|
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
|
||||||
|
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
|
||||||
|
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
47
plants/views/plants.py
Normal file
47
plants/views/plants.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from datetime import date
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from plants.models import Plant, Species, PruningLog
|
||||||
|
from plants.forms import PlantForm, PruningLogForm
|
||||||
|
from plants.utils.pruning import pruning_status
|
||||||
|
|
||||||
|
|
||||||
|
def plant_list(request):
|
||||||
|
qs = Plant.objects.select_related('species').all()
|
||||||
|
q = request.GET.get('q', '')
|
||||||
|
indoor_filter = request.GET.get('filter', '')
|
||||||
|
|
||||||
|
if q:
|
||||||
|
qs = qs.filter(name__icontains=q) | qs.filter(location__icontains=q)
|
||||||
|
if indoor_filter == 'indoor':
|
||||||
|
qs = qs.filter(is_indoor=True)
|
||||||
|
elif indoor_filter == 'outdoor':
|
||||||
|
qs = qs.filter(is_indoor=False)
|
||||||
|
|
||||||
|
template = (
|
||||||
|
'plants/partials/plant_list_results.html'
|
||||||
|
if request.htmx
|
||||||
|
else 'plants/plant_list.html'
|
||||||
|
)
|
||||||
|
return render(request, template, {'plants': qs, 'q': q, 'filter': indoor_filter})
|
||||||
|
|
||||||
|
|
||||||
|
def plant_detail(request, pk):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def plant_add(request):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def plant_edit(request, pk):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def plant_delete(request, pk):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def log_pruning(request, pk):
|
||||||
|
pass
|
||||||
8
plants/views/pruning.py
Normal file
8
plants/views/pruning.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from datetime import date
|
||||||
|
from django.shortcuts import render
|
||||||
|
from plants.models import Plant
|
||||||
|
from plants.utils.pruning import pruning_status
|
||||||
|
|
||||||
|
|
||||||
|
def pruning_calendar(request):
|
||||||
|
return render(request, 'plants/pruning_calendar.html', {})
|
||||||
Loading…
Add table
Reference in a new issue