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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user