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'))
|
||||
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 plants.views import dashboard
|
||||
|
||||
# Stub views for URLs referenced in templates — replaced in later tasks
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
def _stub(request, *args, **kwargs):
|
||||
return HttpResponse('stub')
|
||||
|
||||
from plants.views import dashboard, plants, pruning
|
||||
|
||||
urlpatterns = [
|
||||
path('', dashboard.dashboard, name='dashboard'),
|
||||
path('plants/', _stub, name='plant_list'),
|
||||
path('plants/add/', _stub, name='plant_add'),
|
||||
path('plants/<int:pk>/', _stub, name='plant_detail'),
|
||||
path('pruning/', _stub, name='pruning_calendar'),
|
||||
path('plants/', plants.plant_list, name='plant_list'),
|
||||
path('plants/add/', plants.plant_add, name='plant_add'),
|
||||
path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'),
|
||||
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