Skip to content

Commit 2fd0a2a

Browse files
mpauloskyCopilot
andauthored
feat: LabelInput component + labels on issue cards and detail (#152, #154)
Closes #152 Closes #154 Working as Legolas (Frontend Engineer) Includes: - LabelInput.razor shared component (autocomplete, pills, Backspace/Enter/comma, max-10 guard) - Labels wired into Create.razor and Edit.razor - Label pills on Details.razor and issue cards in Index.razor - Unit tests for AddLabelCommand, RemoveLabelCommand, LabelService, GetLabelSuggestionsQuery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c51ede3 commit 2fd0a2a

12 files changed

Lines changed: 736 additions & 7 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@
106106
</div>
107107
<ValidationMessage For="@(() => _model.CategoryId)" class="mt-1 text-sm text-red-600 dark:text-red-400" />
108108
</div>
109+
110+
<!-- Labels -->
111+
<div>
112+
<LabelInput @bind-Labels="_model.Labels" MaxLabels="10" />
113+
</div>
109114
</div>
110115

111116
<!-- Form Actions -->
@@ -195,7 +200,8 @@
195200
_model.Title!,
196201
_model.Description!,
197202
selectedCategory,
198-
currentUser);
203+
currentUser,
204+
_model.Labels);
199205

200206
if (result.Success && result.Value is not null)
201207
{
@@ -228,5 +234,7 @@
228234

229235
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category is required")]
230236
public string? CategoryId { get; set; }
237+
238+
public List<string> Labels { get; set; } = [];
231239
}
232240
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@
107107
<StatusBadge Status="@_issue.Status" />
108108
<CategoryBadge Category="@_issue.Category" />
109109
</div>
110+
@if (_issue.Labels.Count > 0)
111+
{
112+
<div class="mt-2 flex flex-wrap gap-1.5">
113+
@foreach (var label in _issue.Labels)
114+
{
115+
<span class="inline-flex items-center bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 text-xs font-medium px-2.5 py-0.5 rounded">
116+
@label
117+
</span>
118+
}
119+
</div>
120+
}
110121
</div>
111122
<div class="ml-4 flex-shrink-0 flex space-x-2">
112123
@if (_canVote)

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@
153153
</div>
154154
<ValidationMessage For="@(() => _model.CategoryId)" class="mt-1 text-sm text-red-600 dark:text-red-400" />
155155
</div>
156+
157+
<!-- Labels -->
158+
<div>
159+
<LabelInput @bind-Labels="_model.Labels" MaxLabels="10" />
160+
</div>
156161
</div>
157162

158163
<!-- Form Actions -->
@@ -224,7 +229,8 @@
224229
{
225230
Title = _issue.Title,
226231
Description = _issue.Description,
227-
CategoryId = _issue.Category.Id.ToString()
232+
CategoryId = _issue.Category.Id.ToString(),
233+
Labels = [.. _issue.Labels]
228234
};
229235
}
230236
else
@@ -272,7 +278,8 @@
272278
Id,
273279
_model.Title!,
274280
_model.Description!,
275-
selectedCategory);
281+
selectedCategory,
282+
_model.Labels);
276283

277284
if (result.Success)
278285
{
@@ -305,5 +312,7 @@
305312

306313
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category is required")]
307314
public string? CategoryId { get; set; }
315+
316+
public List<string> Labels { get; set; } = [];
308317
}
309318
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
@using Domain.Features.Issues
2+
3+
@inject ILabelService LabelService
4+
5+
<div class="relative">
6+
<label class="form-label">Labels</label>
7+
<div class="mt-1 flex flex-wrap gap-1.5 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-primary-500 min-h-[42px] items-center cursor-text"
8+
@onclick="FocusInput">
9+
@foreach (var label in Labels)
10+
{
11+
<span class="inline-flex items-center gap-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 text-xs font-medium px-2.5 py-0.5 rounded">
12+
@label
13+
<button type="button" @onclick="() => RemoveLabel(label)" @onclick:stopPropagation="true"
14+
class="ml-0.5 hover:text-blue-600 dark:hover:text-blue-300 focus:outline-none" aria-label="Remove label @label">
15+
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
16+
<path fill-rule="evenodd"
17+
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"
18+
clip-rule="evenodd" />
19+
</svg>
20+
</button>
21+
</span>
22+
}
23+
@if (Labels.Count < MaxLabels)
24+
{
25+
<input @ref="_inputRef"
26+
type="text"
27+
id="label-input"
28+
autocomplete="off"
29+
value="@_inputText"
30+
@oninput="HandleInput"
31+
@onkeydown="HandleKeyDown"
32+
@onblur="HandleBlur"
33+
@onfocus="HandleFocus"
34+
placeholder="@(Labels.Count == 0 ? "Add labels (comma or Enter to confirm)…" : "")"
35+
class="flex-1 min-w-[140px] border-none outline-none bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-0 p-0" />
36+
}
37+
</div>
38+
39+
@if (_showDropdown && _suggestions.Count > 0)
40+
{
41+
<div class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-auto"
42+
role="listbox" aria-label="Label suggestions">
43+
@foreach (var suggestion in _suggestions)
44+
{
45+
<button type="button" role="option"
46+
@onclick="() => SelectSuggestion(suggestion)"
47+
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
48+
@suggestion
49+
</button>
50+
}
51+
</div>
52+
}
53+
54+
@if (Labels.Count >= MaxLabels)
55+
{
56+
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400" role="status">
57+
Maximum of @MaxLabels labels reached.
58+
</p>
59+
}
60+
</div>
61+
62+
@code {
63+
[Parameter] public List<string> Labels { get; set; } = [];
64+
[Parameter] public EventCallback<List<string>> LabelsChanged { get; set; }
65+
[Parameter] public int MaxLabels { get; set; } = 10;
66+
67+
private ElementReference _inputRef;
68+
private string _inputText = string.Empty;
69+
private List<string> _suggestions = [];
70+
private bool _showDropdown;
71+
private CancellationTokenSource? _debounceCts;
72+
73+
private async Task HandleInput(ChangeEventArgs e)
74+
{
75+
_inputText = e.Value?.ToString() ?? string.Empty;
76+
await FetchSuggestionsDebounced();
77+
}
78+
79+
private async Task FetchSuggestionsDebounced()
80+
{
81+
_debounceCts?.Cancel();
82+
_debounceCts = new CancellationTokenSource();
83+
84+
try
85+
{
86+
await Task.Delay(300, _debounceCts.Token);
87+
88+
if (!string.IsNullOrWhiteSpace(_inputText))
89+
{
90+
var results = await LabelService.GetSuggestionsAsync(_inputText.Trim(), 5, _debounceCts.Token);
91+
_suggestions = [.. results.Where(s => !Labels.Any(l => string.Equals(l, s, StringComparison.OrdinalIgnoreCase)))];
92+
_showDropdown = _suggestions.Count > 0;
93+
}
94+
else
95+
{
96+
_suggestions.Clear();
97+
_showDropdown = false;
98+
}
99+
100+
StateHasChanged();
101+
}
102+
catch (OperationCanceledException)
103+
{
104+
// Debounce was reset — ignore.
105+
}
106+
}
107+
108+
private async Task HandleKeyDown(KeyboardEventArgs e)
109+
{
110+
if (e.Key is "Enter" or ",")
111+
{
112+
var text = _inputText.TrimEnd(',');
113+
await ConfirmLabel(text);
114+
}
115+
else if (e.Key == "Backspace" && string.IsNullOrEmpty(_inputText) && Labels.Count > 0)
116+
{
117+
await RemoveLastLabel();
118+
}
119+
else if (e.Key == "Escape")
120+
{
121+
_showDropdown = false;
122+
_suggestions.Clear();
123+
}
124+
}
125+
126+
private async Task HandleFocus()
127+
{
128+
if (!string.IsNullOrWhiteSpace(_inputText))
129+
{
130+
await FetchSuggestionsDebounced();
131+
}
132+
}
133+
134+
private async Task HandleBlur()
135+
{
136+
// Delay to let a suggestion click register before hiding dropdown.
137+
await Task.Delay(150);
138+
_showDropdown = false;
139+
StateHasChanged();
140+
}
141+
142+
private async Task ConfirmLabel(string text)
143+
{
144+
var label = text.Trim();
145+
if (string.IsNullOrWhiteSpace(label))
146+
{
147+
return;
148+
}
149+
150+
if (Labels.Count >= MaxLabels)
151+
{
152+
return;
153+
}
154+
155+
if (Labels.Any(l => string.Equals(l, label, StringComparison.OrdinalIgnoreCase)))
156+
{
157+
_inputText = string.Empty;
158+
return;
159+
}
160+
161+
Labels = [.. Labels, label];
162+
_inputText = string.Empty;
163+
_showDropdown = false;
164+
_suggestions.Clear();
165+
await LabelsChanged.InvokeAsync(Labels);
166+
}
167+
168+
private async Task RemoveLabel(string label)
169+
{
170+
Labels = Labels.Where(l => l != label).ToList();
171+
await LabelsChanged.InvokeAsync(Labels);
172+
}
173+
174+
private async Task RemoveLastLabel()
175+
{
176+
Labels = Labels[..^1];
177+
await LabelsChanged.InvokeAsync(Labels);
178+
}
179+
180+
private async Task SelectSuggestion(string suggestion)
181+
{
182+
await ConfirmLabel(suggestion);
183+
}
184+
185+
private async Task FocusInput()
186+
{
187+
try
188+
{
189+
await _inputRef.FocusAsync();
190+
}
191+
catch
192+
{
193+
// ElementReference may not yet be ready — ignore.
194+
}
195+
}
196+
197+
public void Dispose()
198+
{
199+
_debounceCts?.Cancel();
200+
_debounceCts?.Dispose();
201+
}
202+
}

src/Web/Services/IssueService.cs

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Task<Result<IssueDto>> CreateIssueAsync(
4646
string description,
4747
CategoryDto category,
4848
UserDto author,
49+
IReadOnlyList<string>? labels = null,
4950
CancellationToken cancellationToken = default);
5051

5152
/// <summary>
@@ -56,6 +57,7 @@ Task<Result<IssueDto>> UpdateIssueAsync(
5657
string title,
5758
string description,
5859
CategoryDto category,
60+
IReadOnlyList<string>? labels = null,
5961
CancellationToken cancellationToken = default);
6062

6163
/// <summary>
@@ -190,15 +192,35 @@ public async Task<Result<IssueDto>> CreateIssueAsync(
190192
string description,
191193
CategoryDto category,
192194
UserDto author,
195+
IReadOnlyList<string>? labels = null,
193196
CancellationToken cancellationToken = default)
194197
{
195198
var command = new CreateIssueCommand(title, description, category, author);
196199
var result = await _mediator.Send(command, cancellationToken);
197200

198-
// Notify clients if successful
199201
if (result.Success && result.Value is not null)
200202
{
201-
await _notificationService.NotifyIssueCreatedAsync(result.Value, cancellationToken);
203+
// Attach labels one-by-one using the dedicated command.
204+
if (labels is { Count: > 0 })
205+
{
206+
var issueId = result.Value.Id.ToString();
207+
foreach (var label in labels)
208+
{
209+
if (!string.IsNullOrWhiteSpace(label))
210+
{
211+
await _mediator.Send(new AddLabelCommand(issueId, label), cancellationToken);
212+
}
213+
}
214+
215+
// Re-fetch so the returned DTO includes the labels.
216+
var refreshed = await _mediator.Send(new GetIssueByIdQuery(issueId), cancellationToken);
217+
if (refreshed.Success && refreshed.Value is not null)
218+
{
219+
result = refreshed;
220+
}
221+
}
222+
223+
await _notificationService.NotifyIssueCreatedAsync(result.Value!, cancellationToken);
202224
}
203225

204226
return result;
@@ -209,15 +231,43 @@ public async Task<Result<IssueDto>> UpdateIssueAsync(
209231
string title,
210232
string description,
211233
CategoryDto category,
234+
IReadOnlyList<string>? labels = null,
212235
CancellationToken cancellationToken = default)
213236
{
214237
var command = new UpdateIssueCommand(id, title, description, category);
215238
var result = await _mediator.Send(command, cancellationToken);
216239

217-
// Notify clients if successful
218240
if (result.Success && result.Value is not null)
219241
{
220-
await _notificationService.NotifyIssueUpdatedAsync(result.Value, cancellationToken);
242+
// Sync labels: add new ones, remove stale ones.
243+
if (labels is not null)
244+
{
245+
var currentLabels = result.Value.Labels ?? [];
246+
var desired = labels
247+
.Where(l => !string.IsNullOrWhiteSpace(l))
248+
.Select(l => l.Trim().ToLowerInvariant())
249+
.Distinct()
250+
.ToList();
251+
252+
foreach (var toRemove in currentLabels.Except(desired, StringComparer.OrdinalIgnoreCase))
253+
{
254+
await _mediator.Send(new RemoveLabelCommand(id, toRemove), cancellationToken);
255+
}
256+
257+
foreach (var toAdd in desired.Except(currentLabels, StringComparer.OrdinalIgnoreCase))
258+
{
259+
await _mediator.Send(new AddLabelCommand(id, toAdd), cancellationToken);
260+
}
261+
262+
// Re-fetch so the returned DTO includes the updated labels.
263+
var refreshed = await _mediator.Send(new GetIssueByIdQuery(id), cancellationToken);
264+
if (refreshed.Success && refreshed.Value is not null)
265+
{
266+
result = refreshed;
267+
}
268+
}
269+
270+
await _notificationService.NotifyIssueUpdatedAsync(result.Value!, cancellationToken);
221271
}
222272

223273
return result;

0 commit comments

Comments
 (0)