Skip to content

Commit f9fd20b

Browse files
authored
Merge pull request #603 from Chris0Jeky/feature/93-global-search-launcher
Add global search and quick-action launcher (Ctrl+K)
2 parents dec1398 + 4945441 commit f9fd20b

File tree

15 files changed

+959
-53
lines changed

15 files changed

+959
-53
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Taskdeck.Api.Extensions;
4+
using Taskdeck.Application.Interfaces;
5+
using Taskdeck.Application.Services;
6+
7+
namespace Taskdeck.Api.Controllers;
8+
9+
[ApiController]
10+
[Authorize]
11+
[Route("api/[controller]")]
12+
public class SearchController : AuthenticatedControllerBase
13+
{
14+
private readonly ISearchService _searchService;
15+
16+
public SearchController(ISearchService searchService, IUserContext userContext) : base(userContext)
17+
{
18+
_searchService = searchService;
19+
}
20+
21+
[HttpGet]
22+
public async Task<IActionResult> Search(
23+
[FromQuery] string? q,
24+
CancellationToken cancellationToken = default)
25+
{
26+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
27+
return errorResult!;
28+
29+
var result = await _searchService.SearchAsync(userId, q ?? string.Empty, cancellationToken: cancellationToken);
30+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
31+
}
32+
}

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
4343
services.AddScoped<INotificationService, NotificationService>();
4444
services.AddScoped<IKnowledgeService, KnowledgeService>();
4545
services.AddScoped<IWorkspaceService, WorkspaceService>();
46+
services.AddScoped<ISearchService, SearchService>();
4647
services.AddScoped<IStarterPackManifestValidator, StarterPackManifestValidator>();
4748
services.AddScoped<IStarterPackApplyService, StarterPackApplyService>();
4849
services.AddScoped<IStarterPackCatalogService, StarterPackCatalogService>();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
public record GlobalSearchResultDto(
4+
List<SearchBoardHitDto> Boards,
5+
List<SearchCardHitDto> Cards
6+
);
7+
8+
public record SearchBoardHitDto(
9+
Guid Id,
10+
string Name,
11+
string? Description,
12+
bool IsArchived
13+
);
14+
15+
public record SearchCardHitDto(
16+
Guid Id,
17+
Guid BoardId,
18+
string BoardName,
19+
Guid ColumnId,
20+
string ColumnName,
21+
string Title,
22+
string? Description
23+
);

backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ public interface ICardRepository : IRepository<Card>
1010
Task<IEnumerable<Card>> GetByColumnIdAsync(Guid columnId, CancellationToken cancellationToken = default);
1111
Task<IEnumerable<Card>> SearchAsync(Guid boardId, string? searchText, Guid? labelId, Guid? columnId, CancellationToken cancellationToken = default);
1212
Task<Card?> GetByIdWithLabelsAsync(Guid id, CancellationToken cancellationToken = default);
13+
Task<IEnumerable<Card>> SearchAcrossBoardsAsync(IEnumerable<Guid> boardIds, string searchText, int maxResults, CancellationToken cancellationToken = default);
1314
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
public interface ISearchService
7+
{
8+
Task<Result<GlobalSearchResultDto>> SearchAsync(
9+
Guid userId,
10+
string query,
11+
int maxResults = 20,
12+
CancellationToken cancellationToken = default);
13+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Application.Interfaces;
3+
using Taskdeck.Domain.Common;
4+
using Taskdeck.Domain.Exceptions;
5+
6+
namespace Taskdeck.Application.Services;
7+
8+
public class SearchService : ISearchService
9+
{
10+
private const int MaxBoardResults = 10;
11+
private const int MaxCardResults = 20;
12+
13+
private readonly IUnitOfWork _unitOfWork;
14+
15+
public SearchService(IUnitOfWork unitOfWork)
16+
{
17+
_unitOfWork = unitOfWork;
18+
}
19+
20+
public async Task<Result<GlobalSearchResultDto>> SearchAsync(
21+
Guid userId,
22+
string query,
23+
int maxResults = 20,
24+
CancellationToken cancellationToken = default)
25+
{
26+
if (userId == Guid.Empty)
27+
return Result.Failure<GlobalSearchResultDto>(ErrorCodes.ValidationError, "User ID cannot be empty");
28+
29+
if (string.IsNullOrWhiteSpace(query))
30+
return Result.Success(new GlobalSearchResultDto([], []));
31+
32+
var trimmedQuery = query.Trim();
33+
if (trimmedQuery.Length < 2)
34+
return Result.Success(new GlobalSearchResultDto([], []));
35+
36+
// Get boards the user can read
37+
var readableBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync(
38+
userId,
39+
includeArchived: false,
40+
cancellationToken)).ToList();
41+
42+
// Search boards by name/description
43+
var matchingBoards = readableBoards
44+
.Where(b =>
45+
b.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) ||
46+
(b.Description != null && b.Description.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)))
47+
.Take(MaxBoardResults)
48+
.Select(b => new SearchBoardHitDto(b.Id, b.Name, b.Description, b.IsArchived))
49+
.ToList();
50+
51+
// Search cards across all readable boards
52+
var readableBoardIds = readableBoards.Select(b => b.Id).ToList();
53+
var matchingCards = (await _unitOfWork.Cards.SearchAcrossBoardsAsync(
54+
readableBoardIds,
55+
trimmedQuery,
56+
MaxCardResults,
57+
cancellationToken))
58+
.Select(c => new SearchCardHitDto(
59+
c.Id,
60+
c.BoardId,
61+
c.Board?.Name ?? "Unknown",
62+
c.ColumnId,
63+
c.Column?.Name ?? "Unknown",
64+
c.Title,
65+
c.Description))
66+
.ToList();
67+
68+
return Result.Success(new GlobalSearchResultDto(matchingBoards, matchingCards));
69+
}
70+
}

backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,26 @@ public async Task<IEnumerable<Card>> SearchAsync(
114114
.ThenInclude(cl => cl.Label)
115115
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
116116
}
117+
118+
public async Task<IEnumerable<Card>> SearchAcrossBoardsAsync(
119+
IEnumerable<Guid> boardIds,
120+
string searchText,
121+
int maxResults,
122+
CancellationToken cancellationToken = default)
123+
{
124+
var materializedBoardIds = boardIds.Distinct().ToList();
125+
if (materializedBoardIds.Count == 0 || string.IsNullOrWhiteSpace(searchText))
126+
return [];
127+
128+
return await _dbSet
129+
.AsNoTracking()
130+
.Where(c => materializedBoardIds.Contains(c.BoardId))
131+
.Where(c => c.Title.Contains(searchText) || c.Description.Contains(searchText))
132+
.Include(c => c.Board)
133+
.Include(c => c.Column)
134+
.OrderBy(c => c.BoardId)
135+
.ThenBy(c => c.Position)
136+
.Take(maxResults)
137+
.ToListAsync(cancellationToken);
138+
}
117139
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import http from './http'
2+
3+
export interface SearchBoardHit {
4+
id: string
5+
name: string
6+
description: string | null
7+
isArchived: boolean
8+
}
9+
10+
export interface SearchCardHit {
11+
id: string
12+
boardId: string
13+
boardName: string
14+
columnId: string
15+
columnName: string
16+
title: string
17+
description: string
18+
}
19+
20+
export interface GlobalSearchResult {
21+
boards: SearchBoardHit[]
22+
cards: SearchCardHit[]
23+
}
24+
25+
export const searchApi = {
26+
async search(query: string, signal?: AbortSignal): Promise<GlobalSearchResult> {
27+
const params = new URLSearchParams()
28+
params.append('q', query)
29+
const { data } = await http.get<GlobalSearchResult>(`/search?${params}`, { signal })
30+
return data
31+
},
32+
}

frontend/taskdeck-web/src/components/shell/AppShell.vue

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ function handleCommandActivate(item: CommandItem) {
8484
closeCommandPalette()
8585
}
8686
87+
function handleNavigateToBoard(boardId: string) {
88+
void router.push(`/workspace/boards/${boardId}`)
89+
closeCommandPalette()
90+
}
91+
92+
function handleNavigateToCard(boardId: string, _cardId: string) {
93+
// Navigate to the board containing the card
94+
void router.push(`/workspace/boards/${boardId}`)
95+
closeCommandPalette()
96+
}
97+
8798
// ── Keyboard shortcuts ──
8899
89100
function isTextEntryTarget(target: EventTarget | null): boolean {
@@ -185,6 +196,8 @@ onUnmounted(() => {
185196
:items="commandItems"
186197
@close="closeCommandPalette"
187198
@activate="handleCommandActivate"
199+
@navigate-to-board="handleNavigateToBoard"
200+
@navigate-to-card="handleNavigateToCard"
188201
/>
189202

190203
<ShellKeyboardHelp

0 commit comments

Comments
 (0)