Skip to content

Commit c51ede3

Browse files
mpauloskyCopilot
andauthored
feat: filter issues by label — URL param and quick-filter chips (#150)
Closes #150 Working as Legolas (Frontend Engineer) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6019db1 commit c51ede3

4 files changed

Lines changed: 124 additions & 4 deletions

File tree

src/Domain/DTOs/IssueSearchRequest.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ public record IssueSearchRequest
6060
/// </summary>
6161
public bool IncludeArchived { get; init; }
6262

63+
/// <summary>
64+
/// Gets the labels to filter by (AND semantics - issue must have ALL specified labels).
65+
/// </summary>
66+
public IReadOnlyList<string>? LabelFilter { get; init; }
67+
6368
/// <summary>
6469
/// Gets an empty search request with default values.
6570
/// </summary>
@@ -74,5 +79,6 @@ public record IssueSearchRequest
7479
!string.IsNullOrWhiteSpace(CategoryFilter) ||
7580
!string.IsNullOrWhiteSpace(AuthorId) ||
7681
DateFrom.HasValue ||
77-
DateTo.HasValue;
82+
DateTo.HasValue ||
83+
(LabelFilter?.Count > 0);
7884
}

src/Domain/Features/Issues/Queries/SearchIssuesQuery.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,14 @@ public async Task<Result<PagedResult<IssueDto>>> Handle(
4040

4141
_logger.LogInformation(
4242
"Searching issues - SearchText: {SearchText}, Status: {Status}, Category: {Category}, " +
43-
"Author: {Author}, DateFrom: {DateFrom}, DateTo: {DateTo}, Page: {Page}, PageSize: {PageSize}",
43+
"Author: {Author}, DateFrom: {DateFrom}, DateTo: {DateTo}, Labels: {Labels}, Page: {Page}, PageSize: {PageSize}",
4444
request.SearchText ?? "None",
4545
request.StatusFilter ?? "All",
4646
request.CategoryFilter ?? "All",
4747
request.AuthorId ?? "All",
4848
request.DateFrom?.ToString() ?? "None",
4949
request.DateTo?.ToString() ?? "None",
50+
request.LabelFilter?.Count > 0 ? string.Join(", ", request.LabelFilter) : "None",
5051
request.Page,
5152
request.PageSize);
5253

@@ -116,6 +117,15 @@ public async Task<Result<PagedResult<IssueDto>>> Handle(
116117
issues = issues.Where(i => i.DateCreated <= toDate).ToList();
117118
}
118119

120+
// Apply label filter (AND semantics - issue must have ALL specified labels)
121+
if (request.LabelFilter?.Count > 0)
122+
{
123+
issues = issues
124+
.Where(i => request.LabelFilter.All(label =>
125+
i.Labels.Contains(label, StringComparer.OrdinalIgnoreCase)))
126+
.ToList();
127+
}
128+
119129
var totalCount = issues.Count;
120130

121131
// Apply pagination

src/Web/Components/Pages/Issues/Index.razor

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@
5050
DateTo="@_dateTo" DateToChanged="OnDateToChanged" Statuses="@_statuses" Categories="@_categories"
5151
TotalResults="@((int?)_totalItems)" OnFiltersApplied="OnFiltersApplied" OnFiltersCleared="OnFiltersCleared" />
5252

53+
<!-- Active Label Filters -->
54+
@if (_labelFilter?.Count > 0)
55+
{
56+
<div class="flex flex-wrap items-center gap-2">
57+
<span class="text-sm text-gray-700 dark:text-gray-300">Active label filters:</span>
58+
@foreach (var label in _labelFilter)
59+
{
60+
<button type="button" @onclick="() => RemoveLabelFilter(label)"
61+
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors">
62+
@label
63+
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
64+
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
65+
</svg>
66+
</button>
67+
}
68+
<button type="button" @onclick="ClearAllLabelFilters"
69+
class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 underline">
70+
Clear all
71+
</button>
72+
</div>
73+
}
74+
5375
<!-- Quick sort: Top Voted -->
5476
<div class="flex items-center gap-2">
5577
<button type="button" @onclick="ToggleTopVoted"
@@ -185,6 +207,19 @@
185207
</time>
186208
</div>
187209
</div>
210+
@if (issue.Labels.Count > 0)
211+
{
212+
<div class="mt-2 flex flex-wrap gap-1.5">
213+
@foreach (var label in issue.Labels)
214+
{
215+
<button type="button" @onclick="() => AddLabelFilter(label)" @onclick:stopPropagation="true"
216+
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
217+
title="Filter by this label">
218+
@label
219+
</button>
220+
}
221+
</div>
222+
}
188223
</div>
189224
</a>
190225
</div>
@@ -231,6 +266,7 @@
231266
private string? _categoryFilter;
232267
private DateOnly? _dateFrom;
233268
private DateOnly? _dateTo;
269+
private List<string>? _labelFilter;
234270

235271
// Pagination state
236272
private int _currentPage = 1;
@@ -266,7 +302,8 @@
266302
!string.IsNullOrWhiteSpace(_statusFilter) ||
267303
!string.IsNullOrWhiteSpace(_categoryFilter) ||
268304
_dateFrom.HasValue ||
269-
_dateTo.HasValue;
305+
_dateTo.HasValue ||
306+
(_labelFilter?.Count > 0);
270307

271308
private IEnumerable<IssueDto> DisplayedIssues =>
272309
_sortByVotes
@@ -356,6 +393,21 @@
356393
{
357394
_currentPage = Math.Max(1, pageNumber);
358395
}
396+
397+
if (query.TryGetValue("label", out var labels))
398+
{
399+
_labelFilter = labels.ToString()
400+
.Split(',', StringSplitOptions.RemoveEmptyEntries)
401+
.Select(l => l.Trim())
402+
.Where(l => !string.IsNullOrWhiteSpace(l))
403+
.Distinct(StringComparer.OrdinalIgnoreCase)
404+
.ToList();
405+
406+
if (_labelFilter.Count == 0)
407+
{
408+
_labelFilter = null;
409+
}
410+
}
359411
}
360412

361413
private void UpdateQueryParameters()
@@ -392,6 +444,11 @@
392444
queryParams["page"] = _currentPage.ToString();
393445
}
394446

447+
if (_labelFilter?.Count > 0)
448+
{
449+
queryParams["label"] = string.Join(",", _labelFilter);
450+
}
451+
395452
var newUrl = QueryHelpers.AddQueryString("/issues", queryParams!);
396453
NavigationManager.NavigateTo(newUrl, forceLoad: false, replace: true);
397454
}
@@ -431,6 +488,7 @@
431488
CategoryFilter = _categoryFilter,
432489
DateFrom = _dateFrom,
433490
DateTo = _dateTo,
491+
LabelFilter = _labelFilter,
434492
Page = _currentPage,
435493
PageSize = PAGE_SIZE,
436494
IncludeArchived = false
@@ -503,6 +561,52 @@
503561
_categoryFilter = null;
504562
_dateFrom = null;
505563
_dateTo = null;
564+
_labelFilter = null;
565+
_currentPage = 1;
566+
UpdateQueryParameters();
567+
await LoadIssuesAsync();
568+
}
569+
570+
private async Task AddLabelFilter(string label)
571+
{
572+
if (string.IsNullOrWhiteSpace(label))
573+
{
574+
return;
575+
}
576+
577+
_labelFilter ??= [];
578+
579+
if (!_labelFilter.Contains(label, StringComparer.OrdinalIgnoreCase))
580+
{
581+
_labelFilter.Add(label);
582+
_currentPage = 1;
583+
UpdateQueryParameters();
584+
await LoadIssuesAsync();
585+
}
586+
}
587+
588+
private async Task RemoveLabelFilter(string label)
589+
{
590+
if (_labelFilter is null || string.IsNullOrWhiteSpace(label))
591+
{
592+
return;
593+
}
594+
595+
_labelFilter.RemoveAll(l => l.Equals(label, StringComparison.OrdinalIgnoreCase));
596+
597+
if (_labelFilter.Count == 0)
598+
{
599+
_labelFilter = null;
600+
}
601+
602+
_currentPage = 1;
603+
UpdateQueryParameters();
604+
await LoadIssuesAsync();
605+
}
606+
607+
private async Task ClearAllLabelFilters()
608+
{
609+
_labelFilter = null;
506610
_currentPage = 1;
507611
UpdateQueryParameters();
508612
await LoadIssuesAsync();

src/Web/wwwroot/css/app.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)