feat(FN-1529): implement search query propagation to remote node data

- Add search query propagation so remote nodes receive and use query filters
- Update api-node.ts to accept and forward search query parameters
- Add useRemoteNodeData query key dependency for proper re-fetching on filter changes
- Add api-node.test.ts with tests for search query propagation
- Add comprehensive tests for App component including remote data filtering
- Document search query propagation pitfall in .fusion/memory.md
This commit is contained in:
gsxdsm
2026-04-10 02:59:38 -07:00
parent a3d70e5190
commit dd77c79718
7 changed files with 503 additions and 11 deletions

View File

@@ -59,17 +59,17 @@ function AppInner() {
}
}, [currentNodeId, nodes, clearCurrentNode]);
// Remote node data and events when in remote mode
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id });
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering)
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
const remoteEvents = useRemoteNodeEvents(currentNodeId);
// Use remote data when in remote mode, local data otherwise
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
const effectiveTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : [];
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");
// Tasks hook with project context and search query
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id, searchQuery: searchQuery || undefined } : { searchQuery: searchQuery || undefined }

View File

@@ -113,6 +113,68 @@ describe("api-node", () => {
{ nodeId: "node_abc" },
);
});
it("forwards search query parameter (q) when provided", async () => {
const mockTasks = [
{
id: "FN-001",
title: "Searchable Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockProxyApi.mockResolvedValueOnce(mockTasks);
const result = await fetchRemoteNodeTasks("node_abc", "proj_001", "searchable");
expect(mockProxyApi).toHaveBeenCalledTimes(1);
expect(mockProxyApi).toHaveBeenCalledWith(
"/tasks?projectId=proj_001&q=searchable",
{ nodeId: "node_abc" },
);
expect(result).toEqual(mockTasks);
});
it("properly encodes search query with special characters", async () => {
mockProxyApi.mockResolvedValueOnce([]);
await fetchRemoteNodeTasks("node_abc", "proj_001", "search+query&test");
expect(mockProxyApi).toHaveBeenCalledWith(
"/tasks?projectId=proj_001&q=search%2Bquery%26test",
{ nodeId: "node_abc" },
);
});
it("omits q parameter when searchQuery is undefined", async () => {
mockProxyApi.mockResolvedValueOnce([]);
await fetchRemoteNodeTasks("node_abc", "proj_001");
expect(mockProxyApi).toHaveBeenCalledWith(
"/tasks?projectId=proj_001",
{ nodeId: "node_abc" },
);
});
it("omits q parameter when searchQuery is empty string", async () => {
mockProxyApi.mockResolvedValueOnce([]);
await fetchRemoteNodeTasks("node_abc", "proj_001", "");
expect(mockProxyApi).toHaveBeenCalledWith(
"/tasks?projectId=proj_001",
{ nodeId: "node_abc" },
);
});
});
describe("fetchRemoteNodeProjectHealth", () => {

View File

@@ -25,8 +25,16 @@ export async function fetchRemoteNodeProjects(nodeId: string): Promise<ProjectIn
}
/** Fetch tasks from a specific project on a remote node */
export async function fetchRemoteNodeTasks(nodeId: string, projectId: string): Promise<Task[]> {
return proxyApi<Task[]>(`/tasks?projectId=${encodeURIComponent(projectId)}`, { nodeId });
export async function fetchRemoteNodeTasks(
nodeId: string,
projectId: string,
searchQuery?: string,
): Promise<Task[]> {
const params = new URLSearchParams({ projectId });
if (searchQuery && searchQuery.trim()) {
params.set("q", searchQuery.trim());
}
return proxyApi<Task[]>(`/tasks?${params.toString()}`, { nodeId });
}
/** Fetch project health from a remote node */

View File

@@ -166,6 +166,7 @@ vi.mock("../../hooks/useNodes", () => ({
import { App } from "../../App";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
beforeEach(() => {
vi.clearAllMocks();
@@ -1735,3 +1736,159 @@ describe("App node mode switching", () => {
});
});
});
describe("App search query propagation to remote mode", () => {
// Mock useRemoteNodeData to capture searchQuery parameter
let capturedSearchQuery: string | undefined;
beforeEach(() => {
capturedSearchQuery = undefined;
// Get the mocked useRemoteNodeData and capture the searchQuery
vi.mocked(apiNodeModule.useRemoteNodeData).mockImplementation((nodeId, options) => {
capturedSearchQuery = options?.searchQuery;
return {
projects: [],
tasks: [],
health: null,
loading: false,
error: null,
refresh: vi.fn(),
};
});
});
afterEach(() => {
vi.clearAllMocks();
localStorage.removeItem("fusion-dashboard-current-node");
});
it("passes searchQuery to useRemoteNodeData when in remote mode", async () => {
// Set up mock with remote node
const { useNodes } = await import("../../hooks/useNodes");
vi.mocked(useNodes).mockReturnValue({
nodes: [
{
id: "node_remote_1",
name: "Remote Node 1",
type: "remote" as const,
url: "http://remote:4040",
status: "online" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
// Mock node context to return remote node
mockNodeContextValue.currentNode = {
id: "node_remote_1",
name: "Remote Node 1",
type: "remote",
url: "http://remote:4040",
status: "online",
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
};
mockNodeContextValue.currentNodeId = "node_remote_1";
mockNodeContextValue.isRemote = true;
// Set project mode
localStorage.setItem("kb-dashboard-view-mode", "project");
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// At this point, searchQuery should be passed to useRemoteNodeData
// capturedSearchQuery should be undefined (empty search initially)
expect(capturedSearchQuery).toBeUndefined();
});
it("updates searchQuery in useRemoteNodeData when header search changes", async () => {
// Set up mock with remote node
const { useNodes } = await import("../../hooks/useNodes");
vi.mocked(useNodes).mockReturnValue({
nodes: [
{
id: "node_remote_1",
name: "Remote Node 1",
type: "remote" as const,
url: "http://remote:4040",
status: "online" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
// Mock node context to return remote node
mockNodeContextValue.currentNode = {
id: "node_remote_1",
name: "Remote Node 1",
type: "remote",
url: "http://remote:4040",
status: "online",
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
};
mockNodeContextValue.currentNodeId = "node_remote_1";
mockNodeContextValue.isRemote = true;
// Set project mode
localStorage.setItem("kb-dashboard-view-mode", "project");
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// Click the search toggle button to open search
const searchToggleBtn = screen.getByTestId("desktop-header-search-btn");
expect(searchToggleBtn).toBeInTheDocument();
fireEvent.click(searchToggleBtn);
// Now the search input should be visible
const searchInput = await screen.findByPlaceholderText("Search tasks...");
expect(searchInput).toBeInTheDocument();
// Type in the search input
fireEvent.change(searchInput, { target: { value: "test search" } });
// Wait for the search query to propagate
await waitFor(() => {
expect(capturedSearchQuery).toBe("test search");
});
});
});

View File

@@ -15,6 +15,260 @@ const mockFetchRemoteNodeProjects = vi.mocked(apiNodeModule.fetchRemoteNodeProje
const mockFetchRemoteNodeTasks = vi.mocked(apiNodeModule.fetchRemoteNodeTasks);
const mockFetchRemoteNodeProjectHealth = vi.mocked(apiNodeModule.fetchRemoteNodeProjectHealth);
describe("useRemoteNodeData search query propagation", () => {
beforeEach(() => {
mockFetchRemoteNodeHealth.mockReset();
mockFetchRemoteNodeProjects.mockReset();
mockFetchRemoteNodeTasks.mockReset();
mockFetchRemoteNodeProjectHealth.mockReset();
// Default mock setup for successful fetch
mockFetchRemoteNodeHealth.mockResolvedValueOnce({
status: "online",
version: "1.0.0",
nodeId: "node_abc",
});
mockFetchRemoteNodeProjects.mockResolvedValueOnce([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("forwards searchQuery to fetchRemoteNodeTasks when provided", async () => {
const mockTasks = [
{
id: "FN-001",
title: "Test Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks.mockResolvedValueOnce(mockTasks);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery: "test" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001", "test");
expect(result.current.tasks).toEqual(mockTasks);
});
it("does not forward searchQuery when undefined", async () => {
mockFetchRemoteNodeTasks.mockResolvedValueOnce([]);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001", undefined);
});
it("refetches tasks when searchQuery changes", async () => {
const initialTasks = [
{
id: "FN-001",
title: "Initial Task",
description: "Initial description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
const filteredTasks = [
{
id: "FN-002",
title: "Filtered Task",
description: "Filtered description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks
.mockResolvedValueOnce(initialTasks)
.mockResolvedValueOnce(filteredTasks);
const { result, rerender } = renderHook(
({ searchQuery }: { searchQuery?: string }) =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery }),
{ initialProps: { searchQuery: undefined } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", undefined);
expect(result.current.tasks).toEqual(initialTasks);
// Update searchQuery
rerender({ searchQuery: "filtered" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "filtered");
expect(result.current.tasks).toEqual(filteredTasks);
});
it("refetches tasks when searchQuery is cleared", async () => {
const filteredTasks = [
{
id: "FN-001",
title: "Filtered Task",
description: "Filtered description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
const allTasks = [
{
id: "FN-001",
title: "Filtered Task",
description: "Filtered description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "FN-002",
title: "Other Task",
description: "Other description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks
.mockResolvedValueOnce(filteredTasks)
.mockResolvedValueOnce(allTasks);
const { result, rerender } = renderHook(
({ searchQuery }: { searchQuery?: string }) =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery }),
{ initialProps: { searchQuery: "filtered" } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "filtered");
expect(result.current.tasks).toEqual(filteredTasks);
// Clear searchQuery
rerender({ searchQuery: "" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "");
expect(result.current.tasks).toEqual(allTasks);
});
it("refresh function re-fetches with current searchQuery", async () => {
const mockTasks = [
{
id: "FN-001",
title: "Test Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks
.mockResolvedValueOnce(mockTasks)
.mockResolvedValueOnce(mockTasks);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery: "test" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "test");
// Call refresh
result.current.refresh();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "test");
});
});
describe("useRemoteNodeData", () => {
beforeEach(() => {
mockFetchRemoteNodeHealth.mockReset();
@@ -137,7 +391,7 @@ describe("useRemoteNodeData", () => {
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001");
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001", undefined);
expect(mockFetchRemoteNodeProjectHealth).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeProjectHealth).toHaveBeenCalledWith("node_abc", "proj_001");
expect(result.current.tasks).toEqual(mockTasks);

View File

@@ -16,6 +16,8 @@ import {
export interface UseRemoteNodeDataOptions {
/** Project ID to fetch tasks for */
projectId?: string;
/** Search query to filter tasks */
searchQuery?: string;
}
export interface UseRemoteNodeDataResult {
@@ -42,7 +44,7 @@ export function useRemoteNodeData(
nodeId: string | null,
options?: UseRemoteNodeDataOptions,
): UseRemoteNodeDataResult {
const { projectId } = options ?? {};
const { projectId, searchQuery } = options ?? {};
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
@@ -81,7 +83,7 @@ export function useRemoteNodeData(
// Add tasks and project health fetches if projectId is provided
if (projectId) {
promises.push(fetchRemoteNodeTasks(nodeId, projectId));
promises.push(fetchRemoteNodeTasks(nodeId, projectId, searchQuery));
promises.push(fetchRemoteNodeProjectHealth(nodeId, projectId));
}
@@ -130,7 +132,7 @@ export function useRemoteNodeData(
setLoading(false);
}
}
}, [nodeId, projectId]);
}, [nodeId, projectId, searchQuery]);
// Fetch on mount and when nodeId changes
useEffect(() => {