diff --git a/apps/entries/templates/entries/list.html b/apps/entries/templates/entries/list.html index a4d74b3..f63aae8 100644 --- a/apps/entries/templates/entries/list.html +++ b/apps/entries/templates/entries/list.html @@ -14,6 +14,12 @@

My Entries

+
+ + +
- {% if selected_project %} - Clear + {% if search_query or selected_project %} + + Clear + {% endif %}
@@ -89,12 +98,12 @@

My Entries

{% if is_paginated %}
{% if page_obj.has_previous %} - ← Previous {% endif %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} {% if page_obj.has_next %} - Next → {% endif %}
@@ -103,12 +112,19 @@

My Entries

{% else %}

- {% if selected_project %}No entries for this project.{% else %}No entries yet.{% endif %} + {% if search_query or selected_project %}No entries match your search.{% else %}No entries yet.{% endif %}

+ {% if search_query or selected_project %} + + Clear search + + {% else %} Submit your first entry + {% endif %}
{% endif %} diff --git a/apps/entries/views.py b/apps/entries/views.py index 544d200..76fac1c 100644 --- a/apps/entries/views.py +++ b/apps/entries/views.py @@ -32,12 +32,20 @@ def get_queryset(self): project_id = self.request.GET.get('project') if project_id: qs = qs.filter(project_id=project_id) + q = self.request.GET.get('q', '').strip() + if q: + qs = qs.filter( + Q(title__icontains=q) + | Q(description__icontains=q) + | Q(tags__name__icontains=q) + ).distinct() return qs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['projects'] = Project.objects.filter(is_active=True).order_by('sort_order', 'name') ctx['selected_project'] = self.request.GET.get('project', '') + ctx['search_query'] = self.request.GET.get('q', '').strip() return ctx diff --git a/tests/test_entries.py b/tests/test_entries.py index c9d5fcc..c614dbb 100644 --- a/tests/test_entries.py +++ b/tests/test_entries.py @@ -58,6 +58,68 @@ def test_hides_other_users_entries(self, db, client, entry, project, category): resp = client.get(reverse('entries:list')) assert entry.title not in resp.content.decode() + def _make_entry(self, user, project, category, **kwargs): + today = date.today() + defaults = dict( + author=user, project=project, category=category, period_kind='week', + period_start=today, period_end=today, description='', + ) + defaults.update(kwargs) + return WorkItem.objects.create(**defaults) + + def test_search_filters_by_title(self, db, client, user, project, category): + self._make_entry(user, project, category, title='Quarterly budget review') + self._make_entry(user, project, category, title='Server migration') + client.force_login(user) + resp = client.get(reverse('entries:list'), {'q': 'budget'}) + body = resp.content.decode() + assert 'Quarterly budget review' in body + assert 'Server migration' not in body + + def test_search_filters_by_description(self, db, client, user, project, category): + self._make_entry(user, project, category, title='Alpha', description='notes about kubernetes') + self._make_entry(user, project, category, title='Beta', description='unrelated text') + client.force_login(user) + resp = client.get(reverse('entries:list'), {'q': 'kubernetes'}) + body = resp.content.decode() + assert 'Alpha' in body + assert 'Beta' not in body + + def test_search_filters_by_tag(self, db, client, user, project, category): + tagged = self._make_entry(user, project, category, title='Tagged item') + tagged.tags.add(Tag.objects.create(name='networking')) + self._make_entry(user, project, category, title='Untagged item') + client.force_login(user) + resp = client.get(reverse('entries:list'), {'q': 'network'}) + body = resp.content.decode() + assert 'Tagged item' in body + assert 'Untagged item' not in body + + def test_search_only_returns_own_entries(self, db, client, user, project, category): + other = User.objects.create_user(username='other2', email='other2@example.com', password='pass') + self._make_entry(other, project, category, title='Shared keyword here') + mine = self._make_entry(user, project, category, title='Shared keyword mine') + client.force_login(user) + resp = client.get(reverse('entries:list'), {'q': 'Shared keyword'}) + body = resp.content.decode() + assert 'Shared keyword mine' in body + assert 'Shared keyword here' not in body + + def test_search_no_match_shows_empty_state(self, db, client, user, project, category): + self._make_entry(user, project, category, title='Real entry') + client.force_login(user) + resp = client.get(reverse('entries:list'), {'q': 'zzz-no-match'}) + assert 'No entries match your search.' in resp.content.decode() + + def test_search_does_not_duplicate_multi_tag_matches(self, db, client, user, project, category): + item = self._make_entry(user, project, category, title='Multi tag entry') + item.tags.add(Tag.objects.create(name='netops')) + item.tags.add(Tag.objects.create(name='network-core')) + client.force_login(user) + resp = client.get(reverse('entries:list'), {'q': 'net'}) + # Title should appear once in the table despite matching two tags. + assert resp.content.decode().count('>Multi tag entry<') == 1 + # ── Create ────────────────────────────────────────────────────────────────────