feat(FN-1326): externalize search filter from ListView

- Remove internal search filter from ListView, use searchQuery prop instead
- Show header search bar in list view for consistent filtering
- Remove obsolete list-filter styles (63 lines removed)
- Update tests to reflect new searchQuery prop behavior
This commit is contained in:
gsxdsm
2026-04-09 14:02:35 -07:00
parent 7d484dd972
commit 8b8439a400
6 changed files with 40 additions and 151 deletions

View File

@@ -285,7 +285,7 @@ export function Header({
<div className="header-actions">
{/* Desktop Search - only show in board view */}
{onSearchChange && view === "board" && !isMobile && (
{onSearchChange && (view === "board" || view === "list") && !isMobile && (
<div className="header-search">
<Search size={14} className="header-search-icon" />
<input
@@ -308,7 +308,7 @@ export function Header({
)}
{/* Mobile Search Trigger - show in board and list views when mobile nav is hidden (hideFullNav) or in board view when mobile nav is visible */}
{onSearchChange && isMobile && (hideFullNav || view === "board") && (
{onSearchChange && isMobile && (hideFullNav || view === "board" || view === "list") && (
<>
{!shouldShowMobileSearch ? (
<button

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
import { batchUpdateTaskModels } from "../api";
@@ -126,6 +126,8 @@ interface ListViewProps {
projectName?: string;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** External search query from header search (defaults to "") */
searchQuery?: string;
}
function getStepProgress(steps: TaskStep[]): string {
@@ -159,10 +161,10 @@ export function ListView({
projectId,
projectName,
taskStuckTimeoutMs,
searchQuery = "",
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("id");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const [filter, setFilter] = useState("");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
@@ -322,12 +324,12 @@ export function ListView({
const groupedTasks = useMemo(() => {
// First apply text filter
let filtered = filter
let filtered = searchQuery
? tasks.filter(
(t) =>
t.id.toLowerCase().includes(filter.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
t.description.toLowerCase().includes(filter.toLowerCase())
t.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: [...tasks];
@@ -372,7 +374,7 @@ export function ListView({
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
}, [tasks, filter, sortField, sortDirection, hideDoneTasks, selectedColumn]);
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
@@ -594,21 +596,6 @@ export function ListView({
return (
<div className="list-view">
<div className="list-toolbar">
<div className="list-filter">
<Search size={14} className="filter-icon" />
<input
type="text"
placeholder="Filter by ID or title..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="filter-input"
/>
{filter && (
<button className="filter-clear" onClick={() => setFilter("")}>
×
</button>
)}
</div>
<div className="list-column-toggle" ref={columnDropdownRef}>
<button
className="btn btn-sm"
@@ -771,7 +758,7 @@ export function ListView({
</div>
{filteredCount === 0 ? (
<div className="list-empty">
{filter ? "No tasks match your filter" : "No tasks yet"}
{searchQuery ? "No tasks match your filter" : "No tasks yet"}
</div>
) : isMobile ? (
<div className="list-cards">
@@ -781,7 +768,7 @@ export function ListView({
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
if (filter && isEmpty) return null;
if (searchQuery && isEmpty) return null;
const isCollapsed = collapsedSections.has(column);
@@ -956,7 +943,7 @@ export function ListView({
const isEmpty = columnTasks.length === 0;
// When text filtering, hide empty sections entirely
if (filter && isEmpty) return null;
if (searchQuery && isEmpty) return null;
const isCollapsed = collapsedSections.has(column);

View File

@@ -343,7 +343,7 @@ describe("Header", () => {
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("hides search input when view is 'list'", () => {
it("shows search input when view is 'list'", () => {
const onSearchChange = vi.fn();
render(
<Header
@@ -353,7 +353,7 @@ describe("Header", () => {
onSearchChange={onSearchChange}
/>
);
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("hides search input when view is 'agents'", () => {

View File

@@ -103,7 +103,8 @@ describe("ListView", () => {
it("renders without crashing", () => {
renderListView();
expect(screen.getByPlaceholderText("Filter by ID or title...")).toBeDefined();
// The search/filter is now in the header, not in the list view toolbar
expect(screen.getByText("Columns")).toBeDefined();
});
it("displays tasks in table format", () => {
@@ -128,10 +129,7 @@ describe("ListView", () => {
it("shows empty state when filter matches nothing", () => {
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "nonexistent" } });
renderListView({ tasks, searchQuery: "nonexistent" });
expect(screen.getByText("No tasks match your filter")).toBeDefined();
});
@@ -142,10 +140,7 @@ describe("ListView", () => {
createMockTask({ id: "FN-002", title: "Second Task" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "FN-001" } });
renderListView({ tasks, searchQuery: "FN-001" });
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.queryByText("FN-002")).toBeNull();
@@ -157,10 +152,7 @@ describe("ListView", () => {
createMockTask({ id: "FN-002", title: "Second Task" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Second" } });
renderListView({ tasks, searchQuery: "Second" });
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.getByText("FN-002")).toBeDefined();
@@ -172,32 +164,27 @@ describe("ListView", () => {
createMockTask({ id: "FN-002", title: undefined, description: "Beta description" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
renderListView({ tasks, searchQuery: "Alpha" });
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.queryByText("FN-002")).toBeNull();
});
it("clears filter when clear button is clicked", () => {
it("clears filter when searchQuery is empty", () => {
const tasks = [
createMockTask({ id: "FN-001", title: "First Task" }),
createMockTask({ id: "FN-002", title: "Second Task" }),
];
renderListView({ tasks });
// First render with search query
const { rerender } = renderListView({ tasks, searchQuery: "FN-001" });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "FN-001" } });
// Wait for filter to apply
// Only FN-001 should be visible
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.queryByText("FN-002")).toBeNull();
// Click clear button (×)
const clearButton = screen.getByText("×");
fireEvent.click(clearButton);
// Re-render with empty searchQuery
rerender(<ListView {...renderListView} tasks={tasks} searchQuery="" />);
// Both tasks should be visible again
expect(screen.getByText("FN-001")).toBeDefined();
@@ -501,10 +488,7 @@ describe("ListView", () => {
createMockTask({ id: "FN-003", title: "Gamma" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
renderListView({ tasks, searchQuery: "Alpha" });
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
});
@@ -739,11 +723,7 @@ describe("ListView", () => {
createMockTask({ id: "FN-002", title: "Beta Task", column: "todo" }),
];
renderListView({ tasks });
// Apply filter that only matches triage task
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
renderListView({ tasks, searchQuery: "Alpha" });
// Only triage section should be visible (todo section should be hidden)
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
@@ -916,22 +896,13 @@ describe("ListView Column Filtering", () => {
createMockTask({ id: "FN-003", column: "todo", title: "Alpha Todo Task" }),
];
renderListView({ tasks });
renderListView({ tasks, searchQuery: "Alpha" });
// Click on the triage drop zone to filter by column
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Both triage tasks should be visible
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
expect(screen.queryByText("FN-003")).toBeNull();
// Apply text filter within the triage column
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
// Only Alpha triage task should be visible
// Only Alpha triage task should be visible (text filter + column filter)
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.queryByText("FN-002")).toBeNull();
expect(screen.queryByText("FN-003")).toBeNull();
@@ -1382,16 +1353,13 @@ describe("ListView Hide Done Tasks", () => {
createMockTask({ id: "FN-003", column: "triage", title: "Gamma" }),
];
renderListView({ tasks });
// Hide done + apply filter via props
renderListView({ tasks, searchQuery: "Gamma" });
// Hide done tasks
// Hide done tasks via button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Apply filter
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Gamma" } });
// Completed tasks should remain hidden
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.queryByText("FN-002")).toBeNull();
@@ -1781,7 +1749,7 @@ describe("ListView Collapsible Sections", () => {
createMockTask({ id: "FN-002", column: "triage", title: "Beta Task" }),
];
renderListView({ tasks });
renderListView({ tasks, searchQuery: "Alpha" });
// Collapse triage section
const triageHeader = screen.getAllByRole("row").find(r =>
@@ -1789,14 +1757,10 @@ describe("ListView Collapsible Sections", () => {
);
fireEvent.click(triageHeader!);
// Apply filter
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
// Expand triage section by clicking again (filter change should keep collapsed state)
// Expand triage section by clicking again
fireEvent.click(triageHeader!);
// Only Alpha task should be visible
// Only Alpha task should be visible (filter is applied via prop)
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.queryByText("FN-002")).toBeNull();
});