Skip to content
Merged
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
30 changes: 23 additions & 7 deletions apps/entries/templates/entries/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ <h1 class="text-2xl font-bold text-slate-900">My Entries</h1>

<!-- Filters -->
<form method="get" class="flex flex-wrap gap-3 items-end">
<div class="grow sm:grow-0">
<label class="block text-xs font-medium text-slate-500 mb-1">Search</label>
<input type="text" name="q" value="{{ search_query }}"
placeholder="Title, description, or tag…"
class="w-full sm:w-64 rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-scd-primary focus:ring-1 focus:ring-scd-primary focus:outline-none">
Comment on lines +18 to +21
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1">Project</label>
<select name="project"
Expand All @@ -25,11 +31,14 @@ <h1 class="text-2xl font-bold text-slate-900">My Entries</h1>
</select>
</div>
<button type="submit"
class="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm rounded-md transition-colors">
Filter
class="px-4 py-1.5 bg-scd-primary hover:bg-scd-primary-hover text-white text-sm font-medium rounded-md transition-colors">
Search
</button>
{% if selected_project %}
<a href="{% url 'entries:list' %}" class="text-sm text-slate-500 hover:underline self-end pb-1">Clear</a>
{% if search_query or selected_project %}
<a href="{% url 'entries:list' %}"
class="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm rounded-md transition-colors">
Clear
</a>
{% endif %}
</form>

Expand Down Expand Up @@ -89,12 +98,12 @@ <h1 class="text-2xl font-bold text-slate-900">My Entries</h1>
{% if is_paginated %}
<div class="flex justify-center gap-2 text-sm">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% if selected_project %}&project={{ selected_project }}{% endif %}"
<a href="?page={{ page_obj.previous_page_number }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if search_query %}&q={{ search_query|urlencode }}{% endif %}"
class="px-3 py-1 border border-slate-300 rounded hover:bg-slate-50">← Previous</a>
{% endif %}
<span class="px-3 py-1 text-slate-500">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if selected_project %}&project={{ selected_project }}{% endif %}"
<a href="?page={{ page_obj.next_page_number }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if search_query %}&q={{ search_query|urlencode }}{% endif %}"
class="px-3 py-1 border border-slate-300 rounded hover:bg-slate-50">Next →</a>
{% endif %}
</div>
Expand All @@ -103,12 +112,19 @@ <h1 class="text-2xl font-bold text-slate-900">My Entries</h1>
{% else %}
<div class="bg-white rounded-lg border border-slate-200 shadow-sm px-6 py-12 text-center">
<p class="text-slate-400 mb-3">
{% 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 %}
</p>
{% if search_query or selected_project %}
<a href="{% url 'entries:list' %}"
class="inline-block bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-medium px-4 py-2 rounded-md transition-colors">
Clear search
</a>
Comment on lines +115 to +121
{% else %}
<a href="{% url 'entries:create' %}"
class="inline-block bg-scd-primary hover:bg-scd-primary-hover text-white text-sm font-medium px-4 py-2 rounded-md transition-colors">
Submit your first entry
</a>
{% endif %}
</div>
{% endif %}

Expand Down
8 changes: 8 additions & 0 deletions apps/entries/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
62 changes: 62 additions & 0 deletions tests/test_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down