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:
gsxdsm
2026-04-04 00:50:55 -07:00
parent e9dcca0a28
commit f16e221bf7
5 changed files with 75 additions and 5 deletions

View File

@@ -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 } : {})}

View File

@@ -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) => {

View File

@@ -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", () => {

View File

@@ -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", () => {