feat(FN-1260): add full-text search for tasks and comments
- Add FTS5 virtual table with v21 database migration for task search - Add searchTasks() method to TaskStore with FTS5 query support - Add q= search parameter to GET /api/tasks route for server-side search - Update useTasks hook and frontend API to support searchQuery prop - Update Board.tsx and App.tsx to pass searchQuery through component hierarchy - Add comprehensive tests for FTS5 index and searchTasks functionality
This commit is contained in:
@@ -81,18 +81,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
setArchivedCollapsed((current) => !current);
|
||||
}, []);
|
||||
|
||||
// Filter tasks based on search query (matches id, title, or description)
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tasks;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
t.id.toLowerCase().includes(query) ||
|
||||
(t.title && t.title.toLowerCase().includes(query)) ||
|
||||
t.description.toLowerCase().includes(query)
|
||||
);
|
||||
}, [tasks, searchQuery]);
|
||||
|
||||
// Tasks are already server-filtered when searchQuery is active (via useTasks hook).
|
||||
// Client-side filtering is removed - tasks prop is used directly.
|
||||
// Keep per-column array identities stable for unchanged columns so React.memo(Column)
|
||||
// can skip sibling rerenders during unrelated task updates.
|
||||
const tasksByColumn = useMemo(() => {
|
||||
@@ -100,7 +90,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
COLUMNS.map((column) => [column, [] as Task[]]),
|
||||
) as Record<ColumnType, Task[]>;
|
||||
|
||||
for (const task of filteredTasks) {
|
||||
for (const task of tasks) {
|
||||
nextGrouped[task.column].push(task);
|
||||
}
|
||||
|
||||
@@ -116,14 +106,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
|
||||
tasksByColumnCacheRef.current = stableGrouped;
|
||||
return stableGrouped;
|
||||
}, [filteredTasks]);
|
||||
}, [tasks]);
|
||||
|
||||
// Collect task IDs with GitHub badge info for batch fetching
|
||||
const taskIdsWithBadges = useMemo(() => {
|
||||
return filteredTasks
|
||||
return tasks
|
||||
.filter((t) => t.prInfo || t.issueInfo)
|
||||
.map((t) => t.id);
|
||||
}, [filteredTasks]);
|
||||
}, [tasks]);
|
||||
|
||||
// Batch fetch badge statuses on mount and when visible tasks change
|
||||
useEffect(() => {
|
||||
@@ -171,7 +161,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
allTasks={tasks}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
|
||||
@@ -59,6 +59,11 @@ const mockUseTasks = vi.fn(() => ({
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
// Accept both old and new hook signatures
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: (options?: { projectId?: string; searchQuery?: string }) => mockUseTasks(options),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: () => mockUseTasks(),
|
||||
}));
|
||||
|
||||
@@ -107,14 +107,17 @@ describe("Board", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("filters tasks by ID when search query is provided", () => {
|
||||
it("renders server-filtered tasks by ID when search query is provided", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", description: "Second task", column: "todo" }),
|
||||
createTask({ id: "FN-003", description: "Third task", column: "in-progress" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "FN-002" });
|
||||
// Pre-filtered tasks - only FN-002 matches the search
|
||||
const filteredTasks = [tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "FN-002" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -126,14 +129,17 @@ describe("Board", () => {
|
||||
expect(inProgressTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters tasks by title when search query is provided", () => {
|
||||
it("renders server-filtered tasks by title when search query is provided", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", title: "Fix login bug", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", title: "Add dashboard feature", description: "Second task", column: "todo" }),
|
||||
createTask({ id: "FN-003", title: "Update documentation", description: "Third task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "dashboard" });
|
||||
// Pre-filtered tasks - only dashboard matches
|
||||
const filteredTasks = [tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "dashboard" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -141,14 +147,17 @@ describe("Board", () => {
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("filters tasks by description when search query is provided", () => {
|
||||
it("renders server-filtered tasks by description when search query is provided", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "Implement user authentication", column: "todo" }),
|
||||
createTask({ id: "FN-002", description: "Fix database connection issue", column: "todo" }),
|
||||
createTask({ id: "FN-003", description: "Add caching layer", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "database" });
|
||||
// Pre-filtered tasks - only database matches
|
||||
const filteredTasks = [tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "database" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -156,13 +165,16 @@ describe("Board", () => {
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("search is case-insensitive", () => {
|
||||
it("search is case-insensitive (server handles this)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", title: "Fix Login Bug", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", title: "Add Dashboard Feature", description: "Second task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "login" });
|
||||
// Pre-filtered tasks - only FN-001 matches
|
||||
const filteredTasks = [tasks[0]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "login" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -170,12 +182,15 @@ describe("Board", () => {
|
||||
expect(todoTasks[0].id).toBe("FN-001");
|
||||
});
|
||||
|
||||
it("search is case-insensitive for lowercase query matching uppercase content", () => {
|
||||
it("search is case-insensitive for lowercase query matching uppercase content (server handles this)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-UPPER", title: "UPPERCASE TITLE", description: "DESC", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "upper" });
|
||||
// Pre-filtered tasks - FN-UPPER matches
|
||||
const filteredTasks = [tasks[0]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "upper" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -201,13 +216,16 @@ describe("Board", () => {
|
||||
expect(inProgressTasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows no tasks when search query matches nothing", () => {
|
||||
it("shows no tasks when search query matches nothing (server returns empty)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", description: "Second task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "nonexistent" });
|
||||
// Pre-filtered tasks - empty array because server found no matches
|
||||
const filteredTasks: Task[] = [];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "nonexistent" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -299,24 +317,27 @@ describe("Board", () => {
|
||||
expect(todoTasks[2].id).toBe("FN-003");
|
||||
});
|
||||
|
||||
it("matches tasks across multiple fields simultaneously", () => {
|
||||
it("renders server-filtered tasks matching across multiple fields simultaneously", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
|
||||
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "search" });
|
||||
// Pre-filtered tasks - only the two matching tasks
|
||||
const filteredTasks = [tasks[0], tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "search" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
|
||||
// Should match both tasks with "search" in ID, title, or description
|
||||
// Should have both matching tasks
|
||||
expect(todoTasks).toHaveLength(2);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from search query", () => {
|
||||
it("shows all tasks for whitespace-only search query (server treats as empty)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user