kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1063 from ONREZA/feat/global-search

feat(api,web): add global search with short-id support (DEP-23)

authored by

Andrej and committed by
GitHub
76cfd8ec 5d237da9

+179 -44
+69
apps/api/src/search/controllers/global-search.ts
··· 97 97 ? eq(projectTable.workspaceId, workspaceId) 98 98 : sql`${projectTable.workspaceId} IN ${accessibleWorkspaceIds}`; 99 99 100 + // Check if query matches short-id pattern (e.g. "DEP-23") 101 + const shortIdMatch = query.match(/^([A-Za-z][\w-]*)-(\d+)$/); 102 + 100 103 if (type === "all" || type === "tasks") { 104 + const seenTaskIds = new Set<string>(); 105 + 106 + // If query matches short-id pattern, look up by project slug + task number first 107 + if (shortIdMatch) { 108 + const [, slug, numberStr] = shortIdMatch; 109 + const taskNumber = Number.parseInt(numberStr, 10); 110 + 111 + const shortIdTasks = await db 112 + .select({ 113 + id: taskTable.id, 114 + title: taskTable.title, 115 + description: taskTable.description, 116 + projectId: taskTable.projectId, 117 + projectName: projectTable.name, 118 + projectSlug: projectTable.slug, 119 + workspaceId: projectTable.workspaceId, 120 + workspaceName: workspaceTable.name, 121 + userId: taskTable.userId, 122 + userName: userTable.name, 123 + createdAt: taskTable.createdAt, 124 + taskNumber: taskTable.number, 125 + priority: taskTable.priority, 126 + status: taskTable.status, 127 + }) 128 + .from(taskTable) 129 + .leftJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 130 + .leftJoin( 131 + workspaceTable, 132 + eq(projectTable.workspaceId, workspaceTable.id), 133 + ) 134 + .leftJoin(userTable, eq(taskTable.userId, userTable.id)) 135 + .where( 136 + and( 137 + workspaceFilter, 138 + projectId ? eq(taskTable.projectId, projectId) : undefined, 139 + ilike(projectTable.slug, slug), 140 + eq(taskTable.number, taskNumber), 141 + ), 142 + ) 143 + .limit(1); 144 + 145 + for (const task of shortIdTasks) { 146 + seenTaskIds.add(task.id); 147 + results.push({ 148 + id: task.id, 149 + type: "task", 150 + title: task.title, 151 + description: task.description || undefined, 152 + projectId: task.projectId, 153 + projectName: task.projectName || undefined, 154 + projectSlug: task.projectSlug || undefined, 155 + workspaceId: task.workspaceId || undefined, 156 + workspaceName: task.workspaceName || undefined, 157 + userId: task.userId || undefined, 158 + userName: task.userName || undefined, 159 + createdAt: task.createdAt, 160 + relevanceScore: 10, // Highest relevance for exact short-id match 161 + taskNumber: task.taskNumber || undefined, 162 + priority: task.priority || undefined, 163 + status: task.status, 164 + }); 165 + } 166 + } 167 + 168 + // Also run text search for tasks 101 169 const taskRelevanceScore = sql<number>` 102 170 CASE 103 171 WHEN LOWER(${taskTable.title}) LIKE ${searchPattern} THEN 3 ··· 144 212 const tasks = await taskQuery; 145 213 146 214 for (const task of tasks) { 215 + if (seenTaskIds.has(task.id)) continue; 147 216 results.push({ 148 217 id: task.id, 149 218 type: "task",
+1 -1
apps/web/src/hooks/queries/search/use-global-search.ts
··· 19 19 return useQuery({ 20 20 queryKey: ["search", params], 21 21 queryFn: () => globalSearch(params), 22 - enabled: !!params.q && params.q.length > 2, 22 + enabled: !!params.q && params.q.length >= 1, 23 23 staleTime: 1000 * 30, // 30 seconds 24 24 }); 25 25 }
+109 -43
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/search.tsx
··· 1 - import { createFileRoute, Link } from "@tanstack/react-router"; 2 - import { ArrowLeft, Search } from "lucide-react"; 3 - import { useState } from "react"; 1 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 2 + import { ArrowLeft, Loader2, Search } from "lucide-react"; 3 + import { useCallback, useEffect, useRef, useState } from "react"; 4 4 import WorkspaceLayout from "@/components/common/workspace-layout"; 5 5 import PageTitle from "@/components/page-title"; 6 6 import { Button } from "@/components/ui/button"; 7 7 import { Input } from "@/components/ui/input"; 8 + import useGlobalSearch from "@/hooks/queries/search/use-global-search"; 9 + import { getPriorityIcon } from "@/lib/priority"; 8 10 9 11 export const Route = createFileRoute( 10 12 "/_layout/_authenticated/dashboard/workspace/$workspaceId/search", ··· 14 16 15 17 function SearchComponent() { 16 18 const { workspaceId } = Route.useParams(); 17 - const [searchQuery, setSearchQuery] = useState(""); 18 - const [isSearching, setIsSearching] = useState(false); 19 + const navigate = useNavigate(); 20 + const [searchInput, setSearchInput] = useState(""); 21 + const [debouncedQuery, setDebouncedQuery] = useState(""); 22 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 19 23 20 - const handleSearch = async (query: string) => { 21 - if (!query.trim()) return; 24 + const { data, isLoading, isFetching } = useGlobalSearch({ 25 + q: debouncedQuery, 26 + workspaceId, 27 + type: "tasks", 28 + }); 22 29 23 - setIsSearching(true); 24 - setTimeout(() => { 25 - setIsSearching(false); 26 - }, 1000); 27 - }; 30 + const handleInputChange = useCallback((value: string) => { 31 + setSearchInput(value); 32 + 33 + if (debounceRef.current) { 34 + clearTimeout(debounceRef.current); 35 + } 36 + 37 + debounceRef.current = setTimeout(() => { 38 + setDebouncedQuery(value.trim()); 39 + }, 300); 40 + }, []); 41 + 42 + useEffect(() => { 43 + return () => { 44 + if (debounceRef.current) { 45 + clearTimeout(debounceRef.current); 46 + } 47 + }; 48 + }, []); 49 + 50 + const results = data?.results ?? []; 51 + const hasQuery = debouncedQuery.length > 0; 52 + const showLoading = hasQuery && (isLoading || isFetching); 28 53 29 54 return ( 30 55 <> ··· 45 70 <div className="relative"> 46 71 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" /> 47 72 <Input 48 - placeholder="Search projects, tasks, comments..." 49 - value={searchQuery} 50 - onChange={(e) => setSearchQuery(e.target.value)} 51 - onKeyDown={(e) => { 52 - if (e.key === "Enter") { 53 - handleSearch(searchQuery); 54 - } 55 - }} 73 + placeholder="Search tasks by title or short ID (e.g. DEP-23)..." 74 + value={searchInput} 75 + onChange={(e) => handleInputChange(e.target.value)} 56 76 className="pl-10 h-12 text-lg" 57 77 autoFocus 58 78 /> 59 - <Button 60 - onClick={() => handleSearch(searchQuery)} 61 - disabled={!searchQuery.trim() || isSearching} 62 - className="absolute right-2 top-1/2 transform -translate-y-1/2" 63 - size="sm" 64 - > 65 - {isSearching ? "Searching..." : "Search"} 66 - </Button> 79 + {showLoading && ( 80 + <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" /> 81 + )} 67 82 </div> 68 83 <p className="text-sm text-muted-foreground mt-2"> 69 - Search across all projects, tasks, and comments in this workspace 84 + Search across all projects in this workspace. Use short IDs like 85 + DEP-23 to find specific tasks. 70 86 </p> 71 87 </div> 72 88 73 - {searchQuery ? ( 89 + {hasQuery ? ( 74 90 <div> 75 - <h2 className="text-xl font-semibold mb-4"> 76 - Search Results for "{searchQuery}" 77 - </h2> 78 - 79 - {isSearching ? ( 91 + {showLoading && results.length === 0 ? ( 80 92 <div className="flex items-center justify-center py-12"> 81 93 <div className="text-center space-y-4"> 82 - <div className="w-16 h-16 bg-muted rounded-lg animate-pulse mx-auto" /> 83 - <div className="space-y-2"> 84 - <div className="w-48 h-4 bg-muted rounded animate-pulse mx-auto" /> 85 - <div className="w-64 h-3 bg-muted rounded animate-pulse mx-auto" /> 86 - </div> 94 + <Loader2 className="w-8 h-8 animate-spin text-muted-foreground mx-auto" /> 95 + <p className="text-sm text-muted-foreground"> 96 + Searching... 97 + </p> 87 98 </div> 88 99 </div> 100 + ) : results.length > 0 ? ( 101 + <div className="space-y-1"> 102 + <p className="text-xs text-muted-foreground mb-3"> 103 + {data?.totalCount ?? results.length} result 104 + {(data?.totalCount ?? results.length) !== 1 ? "s" : ""}{" "} 105 + found 106 + </p> 107 + {results.map((result) => ( 108 + <button 109 + key={result.id} 110 + type="button" 111 + onClick={() => { 112 + if (result.type === "task" && result.projectId) { 113 + navigate({ 114 + to: "/dashboard/workspace/$workspaceId/project/$projectId/board", 115 + params: { 116 + workspaceId, 117 + projectId: result.projectId, 118 + }, 119 + search: { taskId: result.id }, 120 + }); 121 + } 122 + }} 123 + className="w-full flex items-center gap-3 px-4 py-3 rounded-lg border border-border bg-background hover:bg-accent/60 transition-colors text-left" 124 + > 125 + <div className="flex-shrink-0 first:[&_svg]:h-4 first:[&_svg]:w-4"> 126 + {getPriorityIcon(result.priority ?? "")} 127 + </div> 128 + 129 + {result.projectSlug && result.taskNumber && ( 130 + <span className="text-xs font-mono text-muted-foreground flex-shrink-0"> 131 + {result.projectSlug}-{result.taskNumber} 132 + </span> 133 + )} 134 + 135 + <div className="flex-1 min-w-0"> 136 + <span className="text-sm text-foreground truncate block"> 137 + {result.title} 138 + </span> 139 + </div> 140 + 141 + {result.projectName && ( 142 + <span className="text-xs text-muted-foreground flex-shrink-0"> 143 + {result.projectName} 144 + </span> 145 + )} 146 + 147 + {result.status && ( 148 + <span className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-muted/55 text-muted-foreground flex-shrink-0"> 149 + {result.status} 150 + </span> 151 + )} 152 + </button> 153 + ))} 154 + </div> 89 155 ) : ( 90 156 <div className="text-center py-12"> 91 157 <Search className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> ··· 101 167 <Search className="w-16 h-16 text-muted-foreground mx-auto mb-4" /> 102 168 <h2 className="text-xl font-semibold mb-2">Start Searching</h2> 103 169 <p className="text-muted-foreground mb-6"> 104 - Enter a search term to find projects, tasks, and comments 170 + Enter a search term to find tasks across all projects 105 171 </p> 106 172 107 173 <div className="space-y-2"> ··· 121 187 variant="outline" 122 188 size="sm" 123 189 onClick={() => { 124 - setSearchQuery(suggestion); 125 - handleSearch(suggestion); 190 + setSearchInput(suggestion); 191 + setDebouncedQuery(suggestion); 126 192 }} 127 193 className="text-xs" 128 194 >