Files
fusion/packages/dashboard/app/hooks/useRemoteNodeData.ts
gsxdsm 5c95d30c86 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
2026-04-10 02:59:38 -07:00

161 lines
4.5 KiB
TypeScript

/**
* useRemoteNodeData hook - fetches projects, tasks, and health data from a remote node.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import type { ProjectInfo } from "../api";
import {
fetchRemoteNodeHealth,
fetchRemoteNodeProjects,
fetchRemoteNodeTasks,
fetchRemoteNodeProjectHealth,
type RemoteNodeHealth,
} from "../api-node";
export interface UseRemoteNodeDataOptions {
/** Project ID to fetch tasks for */
projectId?: string;
/** Search query to filter tasks */
searchQuery?: string;
}
export interface UseRemoteNodeDataResult {
/** Projects from the remote node */
projects: ProjectInfo[];
/** Tasks from the remote node (if projectId is provided) */
tasks: Task[];
/** Health information from the remote node */
health: RemoteNodeHealth | null;
/** Whether data is currently being fetched */
loading: boolean;
/** Error message if fetch failed */
error: string | null;
/** Manually refresh all data */
refresh: () => void;
}
/**
* Hook for fetching data from a remote node.
* Fetches health and projects on mount (and when nodeId changes).
* If projectId is provided, also fetches tasks and project health.
*/
export function useRemoteNodeData(
nodeId: string | null,
options?: UseRemoteNodeDataOptions,
): UseRemoteNodeDataResult {
const { projectId, searchQuery } = options ?? {};
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [health, setHealth] = useState<RemoteNodeHealth | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Track in-flight requests for cleanup
const abortControllerRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async () => {
// No nodeId means no fetching needed
if (!nodeId) {
setProjects([]);
setTasks([]);
setHealth(null);
setLoading(false);
setError(null);
return;
}
// Cancel any in-flight requests
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setLoading(true);
setError(null);
try {
// Fetch health and projects in parallel
const promises: Promise<unknown>[] = [
fetchRemoteNodeHealth(nodeId),
fetchRemoteNodeProjects(nodeId),
];
// Add tasks and project health fetches if projectId is provided
if (projectId) {
promises.push(fetchRemoteNodeTasks(nodeId, projectId, searchQuery));
promises.push(fetchRemoteNodeProjectHealth(nodeId, projectId));
}
const results = await Promise.allSettled(promises);
// Check if aborted
if (abortController.signal.aborted) {
return;
}
// Process results - type-safe access
const healthResult = results[0];
const projectsResult = results[1];
if (healthResult.status === "rejected") {
setError(`Failed to fetch node health: ${healthResult.reason}`);
setLoading(false);
return;
}
setHealth(healthResult.value as RemoteNodeHealth);
if (projectsResult.status === "rejected") {
setError(`Failed to fetch projects: ${projectsResult.reason}`);
setLoading(false);
return;
}
setProjects(projectsResult.value as ProjectInfo[]);
// Process optional results - tasks are at index 2
if (projectId && results[2]) {
const tasksResult = results[2];
if (tasksResult.status === "rejected") {
setError(`Failed to fetch tasks: ${tasksResult.reason}`);
setLoading(false);
return;
}
if (tasksResult.status === "fulfilled") {
setTasks(tasksResult.value as Task[]);
}
}
setLoading(false);
} catch (err) {
if (!abortController.signal.aborted) {
setError(err instanceof Error ? err.message : "Unknown error");
setLoading(false);
}
}
}, [nodeId, projectId, searchQuery]);
// Fetch on mount and when nodeId changes
useEffect(() => {
void fetchData();
// Cleanup: abort in-flight requests on unmount or when dependencies change
return () => {
abortControllerRef.current?.abort();
};
}, [fetchData]);
// Refresh function for manual re-fetch
const refresh = useCallback(() => {
void fetchData();
}, [fetchData]);
return {
projects,
tasks,
health,
loading,
error,
refresh,
};
}