Files
fusion/packages/dashboard/app/hooks/useDocuments.ts
Fusion e0f1ec5bd7 feat(FN-1842): add Documents view with search and grouping
- Add GET /documents API endpoint in dashboard routes
- Add getAllDocuments store method with search support in core
- Add useDocuments hook and API wrapper for frontend consumption
- Add DocumentsView component with search, grouping by type/status, and sortable columns
- Add Documents nav item to Header and MobileNavBar with route /documents
- Integrate DocumentsView into App routing
- Add tests for DocumentsView and useDocuments hook
- Add changeset for @gsxdsm/fusion
2026-04-16 08:52:01 -07:00

114 lines
3.2 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import type { TaskDocumentWithTask } from "@fusion/core";
import { fetchAllDocuments } from "../api";
export interface UseDocumentsResult {
/** List of all documents across tasks */
documents: TaskDocumentWithTask[];
/** Loading state - true only for initial fetch, false during refresh/search */
loading: boolean;
/** Error message if fetch failed */
error: string | null;
/** Refresh documents from the server */
refresh: () => Promise<void>;
}
/**
* Hook for fetching all documents across tasks with optional search.
*
* Loading behavior: `loading` is true only during the initial fetch.
* Refresh or search changes do NOT set `loading` to true, keeping
* previously loaded data visible. This prevents skeleton flicker
* during search filtering and manual refreshes.
*/
export function useDocuments(options?: {
/** Project ID for project-scoped fetching */
projectId?: string;
/** Search query for filtering documents */
searchQuery?: string;
}): UseDocumentsResult {
const { projectId, searchQuery } = options ?? {};
const [documents, setDocuments] = useState<TaskDocumentWithTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Track if we've completed the initial load
const initialLoadCompleteRef = useRef(false);
// Debounce timer for search
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Fetch documents from the server.
* Background updates (refresh, search) do NOT set loading=true.
*/
const refresh = useCallback(async () => {
// Cancel any in-flight requests
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
// Only set loading on initial load
const isInitial = !initialLoadCompleteRef.current;
if (isInitial) {
setLoading(true);
}
setError(null);
try {
const result = await fetchAllDocuments(
searchQuery ? { q: searchQuery } : undefined,
projectId,
);
setDocuments(result);
initialLoadCompleteRef.current = true;
setLoading(false);
} catch (err) {
if (abortRef.current?.signal.aborted) {
// Request was cancelled - ignore
return;
}
setError(err instanceof Error ? err.message : String(err));
if (isInitial) {
setLoading(false);
}
}
}, [projectId, searchQuery]);
// Debounced search effect
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
void refresh();
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [refresh]);
// Initial fetch - intentionally empty deps, only runs on mount
useEffect(() => {
void refresh();
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
// eslint-disable-next-line @typescript-eslint/no-use-before-define
}, []);
return {
documents,
loading,
error,
refresh,
};
}