feat(FN-831): make board search bypass column pagination
- When board search is active, all matching tasks are shown regardless of column page settings - Board.tsx passes search query down to Column; Column skips pagination when query is present - Add Board and Column test coverage for search-bypasses-pagination behavior - Update README to document the search pagination bypass
This commit is contained in:
@@ -62,6 +62,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Normalized search-active signal: trimmed and non-empty
|
||||
const isSearchActive = searchQuery.trim() !== "";
|
||||
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
||||
triage: [],
|
||||
todo: [],
|
||||
@@ -172,6 +174,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
isSearchActive={isSearchActive}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
|
||||
@@ -51,9 +51,11 @@ interface ColumnProps {
|
||||
favoriteModels?: string[];
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
onToggleModelFavorite?: (modelId: string) => void;
|
||||
/** When true, search is active — bypass pagination so all matching tasks are visible. */
|
||||
isSearchActive?: boolean;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
@@ -61,7 +63,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
// Archived column is collapsed by default - don't show drag state when collapsed
|
||||
const isArchived = column === "archived";
|
||||
const isCollapsed = isArchived && collapsed;
|
||||
const shouldPaginate = !isArchived && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD;
|
||||
// When search is active, skip pagination so all matching tasks are visible
|
||||
const shouldPaginate = !isArchived && !isSearchActive && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleTaskCount((current) => {
|
||||
|
||||
@@ -10,10 +10,10 @@ const columnRenderCounts: Record<string, number> = {};
|
||||
|
||||
// Mock child components so we only test Board's own rendering
|
||||
vi.mock("../Column", () => ({
|
||||
Column: React.memo(({ column, tasks, onToggleCollapse, availableModels, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; availableModels?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void }) => {
|
||||
Column: React.memo(({ column, tasks, onToggleCollapse, availableModels, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; availableModels?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean }) => {
|
||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||
return (
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"}>
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"}>
|
||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||
</div>
|
||||
);
|
||||
@@ -329,6 +329,37 @@ describe("Board", () => {
|
||||
// Whitespace-only query should be treated as empty, showing all tasks
|
||||
expect(todoTasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes isSearchActive=true to columns when search query is non-empty", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "first" });
|
||||
|
||||
for (const col of COLUMNS) {
|
||||
const columnEl = screen.getByTestId(`column-${col}`);
|
||||
expect(columnEl.getAttribute("data-is-search-active")).toBe("true");
|
||||
}
|
||||
});
|
||||
|
||||
it("passes isSearchActive=false to columns when search query is empty", () => {
|
||||
renderBoard({ searchQuery: "" });
|
||||
|
||||
for (const col of COLUMNS) {
|
||||
const columnEl = screen.getByTestId(`column-${col}`);
|
||||
expect(columnEl.getAttribute("data-is-search-active")).toBe("false");
|
||||
}
|
||||
});
|
||||
|
||||
it("passes isSearchActive=false to columns when search query is whitespace-only", () => {
|
||||
renderBoard({ searchQuery: " " });
|
||||
|
||||
for (const col of COLUMNS) {
|
||||
const columnEl = screen.getByTestId(`column-${col}`);
|
||||
expect(columnEl.getAttribute("data-is-search-active")).toBe("false");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a .board-project-context badge", () => {
|
||||
|
||||
@@ -187,6 +187,38 @@ describe("Column pagination", () => {
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("disables pagination when isSearchActive is true, showing all tasks", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} isSearchActive={true} />);
|
||||
|
||||
// All 110 tasks should be visible — no pagination applied during active search
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(110);
|
||||
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("restores pagination when isSearchActive changes back to false", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
const { rerender } = render(<Column {...defaultProps} column="todo" tasks={tasks} isSearchActive={true} />);
|
||||
|
||||
// All tasks visible during search
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(110);
|
||||
|
||||
// Search cleared — pagination resumes
|
||||
rerender(<Column {...defaultProps} column="todo" tasks={tasks} isSearchActive={false} />);
|
||||
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(50);
|
||||
expect(screen.getByRole("button", { name: /Load 25 more/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves non-search pagination behavior when isSearchActive is not provided", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} />);
|
||||
|
||||
// Default (undefined isSearchActive) should still paginate
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(50);
|
||||
expect(screen.getByRole("button", { name: /Load 25 more/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column QuickEntryBox", () => {
|
||||
|
||||
Reference in New Issue
Block a user