-
Notifications
You must be signed in to change notification settings - Fork 0
Add global search and quick-action launcher (Ctrl+K) #603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f7c0ec1
6fe147b
b6e13eb
a8bfc06
ab63773
2e0e03e
3ee98fb
d3b6a25
4945441
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Taskdeck.Api.Extensions; | ||
| using Taskdeck.Application.Interfaces; | ||
| using Taskdeck.Application.Services; | ||
|
|
||
| namespace Taskdeck.Api.Controllers; | ||
|
|
||
| [ApiController] | ||
| [Authorize] | ||
| [Route("api/[controller]")] | ||
| public class SearchController : AuthenticatedControllerBase | ||
| { | ||
| private readonly ISearchService _searchService; | ||
|
|
||
| public SearchController(ISearchService searchService, IUserContext userContext) : base(userContext) | ||
| { | ||
| _searchService = searchService; | ||
| } | ||
|
|
||
| [HttpGet] | ||
| public async Task<IActionResult> Search( | ||
| [FromQuery] string? q, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| if (!TryGetCurrentUserId(out var userId, out var errorResult)) | ||
| return errorResult!; | ||
|
|
||
| var result = await _searchService.SearchAsync(userId, q ?? string.Empty, cancellationToken: cancellationToken); | ||
| return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| namespace Taskdeck.Application.DTOs; | ||
|
|
||
| public record GlobalSearchResultDto( | ||
| List<SearchBoardHitDto> Boards, | ||
| List<SearchCardHitDto> Cards | ||
| ); | ||
|
|
||
| public record SearchBoardHitDto( | ||
| Guid Id, | ||
| string Name, | ||
| string? Description, | ||
| bool IsArchived | ||
| ); | ||
|
|
||
| public record SearchCardHitDto( | ||
| Guid Id, | ||
| Guid BoardId, | ||
| string BoardName, | ||
| Guid ColumnId, | ||
| string ColumnName, | ||
| string Title, | ||
| string? Description | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| using Taskdeck.Application.DTOs; | ||
| using Taskdeck.Domain.Common; | ||
|
|
||
| namespace Taskdeck.Application.Services; | ||
|
|
||
| public interface ISearchService | ||
| { | ||
| Task<Result<GlobalSearchResultDto>> SearchAsync( | ||
| Guid userId, | ||
| string query, | ||
| int maxResults = 20, | ||
| CancellationToken cancellationToken = default); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| using Taskdeck.Application.DTOs; | ||
| using Taskdeck.Application.Interfaces; | ||
| using Taskdeck.Domain.Common; | ||
| using Taskdeck.Domain.Exceptions; | ||
|
|
||
| namespace Taskdeck.Application.Services; | ||
|
|
||
| public class SearchService : ISearchService | ||
| { | ||
| private const int MaxBoardResults = 10; | ||
| private const int MaxCardResults = 20; | ||
|
|
||
| private readonly IUnitOfWork _unitOfWork; | ||
|
|
||
| public SearchService(IUnitOfWork unitOfWork) | ||
| { | ||
| _unitOfWork = unitOfWork; | ||
| } | ||
|
|
||
| public async Task<Result<GlobalSearchResultDto>> SearchAsync( | ||
| Guid userId, | ||
| string query, | ||
| int maxResults = 20, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| if (userId == Guid.Empty) | ||
| return Result.Failure<GlobalSearchResultDto>(ErrorCodes.ValidationError, "User ID cannot be empty"); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(query)) | ||
| return Result.Success(new GlobalSearchResultDto([], [])); | ||
|
|
||
| var trimmedQuery = query.Trim(); | ||
| if (trimmedQuery.Length < 2) | ||
| return Result.Success(new GlobalSearchResultDto([], [])); | ||
|
|
||
| // Get boards the user can read | ||
| var readableBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync( | ||
| userId, | ||
| includeArchived: false, | ||
| cancellationToken)).ToList(); | ||
|
Comment on lines
+37
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fetching all readable boards into memory via |
||
|
|
||
| // Search boards by name/description | ||
| var matchingBoards = readableBoards | ||
| .Where(b => | ||
| b.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) || | ||
| (b.Description != null && b.Description.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase))) | ||
| .Take(MaxBoardResults) | ||
| .Select(b => new SearchBoardHitDto(b.Id, b.Name, b.Description, b.IsArchived)) | ||
| .ToList(); | ||
|
|
||
| // Search cards across all readable boards | ||
| var readableBoardIds = readableBoards.Select(b => b.Id).ToList(); | ||
| var matchingCards = (await _unitOfWork.Cards.SearchAcrossBoardsAsync( | ||
| readableBoardIds, | ||
| trimmedQuery, | ||
| MaxCardResults, | ||
| cancellationToken)) | ||
| .Select(c => new SearchCardHitDto( | ||
| c.Id, | ||
| c.BoardId, | ||
| c.Board?.Name ?? "Unknown", | ||
| c.ColumnId, | ||
| c.Column?.Name ?? "Unknown", | ||
| c.Title, | ||
| c.Description)) | ||
| .ToList(); | ||
|
|
||
| return Result.Success(new GlobalSearchResultDto(matchingBoards, matchingCards)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,32 @@ | ||||||
| import http from './http' | ||||||
|
|
||||||
| export interface SearchBoardHit { | ||||||
| id: string | ||||||
| name: string | ||||||
| description: string | null | ||||||
| isArchived: boolean | ||||||
| } | ||||||
|
|
||||||
| export interface SearchCardHit { | ||||||
| id: string | ||||||
| boardId: string | ||||||
| boardName: string | ||||||
| columnId: string | ||||||
| columnName: string | ||||||
| title: string | ||||||
| description: string | ||||||
| } | ||||||
|
|
||||||
| export interface GlobalSearchResult { | ||||||
| boards: SearchBoardHit[] | ||||||
| cards: SearchCardHit[] | ||||||
| } | ||||||
|
|
||||||
| export const searchApi = { | ||||||
| async search(query: string, signal?: AbortSignal): Promise<GlobalSearchResult> { | ||||||
| const params = new URLSearchParams() | ||||||
| params.append('q', query) | ||||||
| const { data } = await http.get<GlobalSearchResult>(`/search?${params}`, { signal }) | ||||||
| return data | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a leading slash in the request path (
Suggested change
|
||||||
| }, | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
maxResultsparameter is currently defined in the method signature but ignored in the implementation. The service uses hardcoded constantsMaxBoardResults(10) andMaxCardResults(20) instead. This makes the API parameter misleading as callers cannot actually control the result set size.