diff --git a/builds/typescript/client_web/src/hooks/useProjects.test.ts b/builds/typescript/client_web/src/hooks/useProjects.test.ts new file mode 100644 index 0000000..2ddaa04 --- /dev/null +++ b/builds/typescript/client_web/src/hooks/useProjects.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; + +import type { Project, ProjectFile } from "@/types/ui"; + +import { useProjects } from "./useProjects"; + +const listProjectsMock = vi.fn<() => Promise>(); +const getProjectFilesMock = vi.fn<(projectId: string) => Promise>(); +const createProjectMock = vi.fn<(name: string) => Promise>(); +const deleteProjectMock = vi.fn<(id: string) => Promise>(); +const renameProjectMock = vi.fn<(id: string, name: string) => Promise>(); + +vi.mock("@/api/gateway-adapter", () => ({ + listProjects: () => listProjectsMock(), + getProjectFiles: (projectId: string) => getProjectFilesMock(projectId), + createProject: (name: string) => createProjectMock(name), + deleteProject: (id: string) => deleteProjectMock(id), + renameProject: (id: string, name: string) => renameProjectMock(id, name), +})); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const defaultProjects: Project[] = [ + { + id: "braindrive-plus-one", + name: "BrainDrive+1", + icon: "sparkles", + conversationId: "conv-plus-one", + }, + { + id: "finance", + name: "Finance", + icon: "dollar-sign", + conversationId: "conv-finance", + }, +]; + +describe("useProjects", () => { + beforeEach(() => { + listProjectsMock.mockReset(); + getProjectFilesMock.mockReset(); + createProjectMock.mockReset(); + deleteProjectMock.mockReset(); + renameProjectMock.mockReset(); + + listProjectsMock.mockResolvedValue(defaultProjects); + getProjectFilesMock.mockResolvedValue([]); + createProjectMock.mockResolvedValue(defaultProjects[1]!); + deleteProjectMock.mockResolvedValue(); + renameProjectMock.mockResolvedValue(); + }); + + it("does not show projects loading state during background refreshes", async () => { + const { result } = renderHook(() => useProjects()); + + await waitFor(() => { + expect(result.current.isLoadingProjects).toBe(false); + }); + + const nextRefresh = deferred(); + listProjectsMock.mockReturnValueOnce(nextRefresh.promise); + + act(() => { + result.current.refreshProjects(); + }); + + expect(result.current.isLoadingProjects).toBe(false); + + nextRefresh.resolve(defaultProjects); + + await waitFor(() => { + expect(listProjectsMock).toHaveBeenCalledTimes(2); + }); + }); + + it("does not re-fetch files when selecting the active project", async () => { + const { result } = renderHook(() => useProjects()); + + await waitFor(() => { + expect(result.current.selectedProjectId).toBe("braindrive-plus-one"); + expect(result.current.isLoadingFiles).toBe(false); + }); + + expect(getProjectFilesMock).toHaveBeenCalledTimes(1); + + act(() => { + result.current.selectProject("braindrive-plus-one"); + }); + + expect(getProjectFilesMock).toHaveBeenCalledTimes(1); + expect(result.current.isLoadingFiles).toBe(false); + }); +}); diff --git a/builds/typescript/client_web/src/hooks/useProjects.ts b/builds/typescript/client_web/src/hooks/useProjects.ts index 0742866..910d53a 100644 --- a/builds/typescript/client_web/src/hooks/useProjects.ts +++ b/builds/typescript/client_web/src/hooks/useProjects.ts @@ -55,7 +55,12 @@ export function useProjects(): { function refreshProjects() { const requestId = projectsRequestIdRef.current + 1; projectsRequestIdRef.current = requestId; - setIsLoadingProjects(true); + const shouldShowLoadingState = projects.length === 0; + + if (shouldShowLoadingState) { + setIsLoadingProjects(true); + } + setProjectsError(null); void (async () => { @@ -72,10 +77,13 @@ export function useProjects(): { return; } - setProjects([]); + if (shouldShowLoadingState) { + setProjects([]); + } + setProjectsError(toError(error)); } finally { - if (projectsRequestIdRef.current === requestId) { + if (projectsRequestIdRef.current === requestId && shouldShowLoadingState) { setIsLoadingProjects(false); } } @@ -100,9 +108,13 @@ export function useProjects(): { selectProject(bdPlusOne.id); } } - }, [projects]); + }, [projects, selectedProjectId]); function selectProject(id: string) { + if (id === selectedProjectId) { + return; + } + setSelectedProjectId(id); setProjectFiles([]); setIsLoadingFiles(true);