diff --git a/src/views/workflow-history/hooks/__tests__/use-workflow-history-fetcher.test.tsx b/src/views/workflow-history/hooks/__tests__/use-workflow-history-fetcher.test.tsx new file mode 100644 index 000000000..8f67dcbb7 --- /dev/null +++ b/src/views/workflow-history/hooks/__tests__/use-workflow-history-fetcher.test.tsx @@ -0,0 +1,196 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { act, renderHook, waitFor } from '@/test-utils/rtl'; + +import workflowHistoryMultiPageFixture from '../../__fixtures__/workflow-history-multi-page-fixture'; +import { workflowPageUrlParams } from '../../__fixtures__/workflow-page-url-params'; +import WorkflowHistoryFetcher from '../../helpers/workflow-history-fetcher'; +import useWorkflowHistoryFetcher from '../use-workflow-history-fetcher'; + +jest.mock('../../helpers/workflow-history-fetcher'); + +const mockParams = { + ...workflowPageUrlParams, + pageSize: 50, + waitForNewEvent: true, +}; +let mockFetcherInstance: jest.Mocked; +let mockOnChangeCallback: jest.Mock; +let mockUnsubscribe: jest.Mock; + +function setup() { + const hookResult = renderHook(() => useWorkflowHistoryFetcher(mockParams)); + + return { + ...hookResult, + mockFetcherInstance, + mockOnChangeCallback, + mockUnsubscribe, + }; +} + +describe(useWorkflowHistoryFetcher.name, () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockOnChangeCallback = jest.fn(); + mockUnsubscribe = jest.fn(); + + mockFetcherInstance = { + start: jest.fn(), + stop: jest.fn(), + destroy: jest.fn(), + fetchSingleNextPage: jest.fn(), + onChange: jest.fn((callback) => { + mockOnChangeCallback.mockImplementation(callback); + return mockUnsubscribe; + }), + getCurrentState: jest.fn(() => ({ + data: undefined, + error: null, + isError: false, + isLoading: false, + isPending: true, + isFetchingNextPage: false, + hasNextPage: false, + status: 'pending' as const, + })), + } as unknown as jest.Mocked; + + ( + WorkflowHistoryFetcher as jest.MockedClass + ).mockImplementation(() => mockFetcherInstance); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a WorkflowHistoryFetcher instance with correct params', () => { + setup(); + + expect(WorkflowHistoryFetcher).toHaveBeenCalledWith( + expect.any(QueryClient), + mockParams + ); + expect(WorkflowHistoryFetcher).toHaveBeenCalledTimes(1); + }); + + it('should reuse the same fetcher instance on re-renders', () => { + const { rerender } = setup(); + + rerender(); + rerender(); + + expect(WorkflowHistoryFetcher).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to fetcher state changes on mount', () => { + setup(); + + expect(mockFetcherInstance.onChange).toHaveBeenCalledTimes(1); + }); + + it('should start fetcher to load first page on mount', () => { + setup(); + + expect(mockFetcherInstance.start).toHaveBeenCalledWith( + expect.any(Function) + ); + expect(mockFetcherInstance.start).toHaveBeenCalledTimes(1); + }); + + it('should return initial history query state', () => { + const { result } = setup(); + + expect(result.current.historyQuery).toBeDefined(); + expect(result.current.historyQuery.isPending).toBe(true); + }); + + it('should update historyQuery when fetcher state changes', async () => { + const { result, mockOnChangeCallback } = setup(); + + const newState = { + data: { + pages: [workflowHistoryMultiPageFixture[0]], + pageParams: [], + }, + error: null, + isError: false, + isLoading: false, + isPending: false, + isFetchingNextPage: false, + hasNextPage: true, + status: 'success' as const, + }; + + act(() => { + mockOnChangeCallback(newState); + }); + + await waitFor(() => { + expect(result.current.historyQuery.status).toBe('success'); + }); + }); + + it('should call fetcher.start() with custom shouldContinue callback passed to startLoadingHistory', () => { + const { result, mockFetcherInstance } = setup(); + const customShouldContinue = jest.fn(() => false); + + act(() => { + result.current.startLoadingHistory(customShouldContinue); + }); + + expect(mockFetcherInstance.start).toHaveBeenCalledWith( + customShouldContinue + ); + }); + + it('should call fetcher.stop() within stopLoadingHistory', () => { + const { result, mockFetcherInstance } = setup(); + + act(() => { + result.current.stopLoadingHistory(); + }); + + expect(mockFetcherInstance.stop).toHaveBeenCalledTimes(1); + }); + + it('should call fetcher.fetchSingleNextPage() within fetchSingleNextPage', () => { + const { result, mockFetcherInstance } = setup(); + + act(() => { + result.current.fetchSingleNextPage(); + }); + + expect(mockFetcherInstance.fetchSingleNextPage).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe from onChange when unmounted', () => { + const { unmount, mockUnsubscribe } = setup(); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should call fetcher.unmount() when component unmounts', () => { + const { unmount, mockFetcherInstance } = setup(); + + unmount(); + + expect(mockFetcherInstance.destroy).toHaveBeenCalledTimes(1); + }); + + it('should return all expected methods and state', () => { + const { result } = setup(); + + expect(result.current).toHaveProperty('historyQuery'); + expect(result.current).toHaveProperty('startLoadingHistory'); + expect(result.current).toHaveProperty('stopLoadingHistory'); + expect(result.current).toHaveProperty('fetchSingleNextPage'); + expect(typeof result.current.startLoadingHistory).toBe('function'); + expect(typeof result.current.stopLoadingHistory).toBe('function'); + expect(typeof result.current.fetchSingleNextPage).toBe('function'); + }); +}); diff --git a/src/views/workflow-history/hooks/use-workflow-history-fetcher.ts b/src/views/workflow-history/hooks/use-workflow-history-fetcher.ts new file mode 100644 index 000000000..c287edfd3 --- /dev/null +++ b/src/views/workflow-history/hooks/use-workflow-history-fetcher.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { + type InfiniteData, + type InfiniteQueryObserverResult, + useQueryClient, +} from '@tanstack/react-query'; + +import useThrottledState from '@/hooks/use-throttled-state'; +import { + type WorkflowHistoryQueryParams, + type GetWorkflowHistoryResponse, + type RouteParams, +} from '@/route-handlers/get-workflow-history/get-workflow-history.types'; +import { type RequestError } from '@/utils/request/request-error'; + +import WorkflowHistoryFetcher from '../helpers/workflow-history-fetcher'; +import { type ShouldContinueCallback } from '../helpers/workflow-history-fetcher.types'; + +export default function useWorkflowHistoryFetcher( + params: WorkflowHistoryQueryParams & RouteParams, + throttleMs: number = 2000 +) { + const queryClient = useQueryClient(); + const fetcherRef = useRef(null); + + if (!fetcherRef.current) { + fetcherRef.current = new WorkflowHistoryFetcher(queryClient, params); + } + + const [historyQuery, setHistoryQuery] = useThrottledState< + InfiniteQueryObserverResult< + InfiniteData, + RequestError + > + >(fetcherRef.current.getCurrentState(), throttleMs, { + leading: true, + trailing: true, + }); + + useEffect(() => { + if (!fetcherRef.current) return; + + const unsubscribe = fetcherRef.current.onChange((state) => { + const pagesCount = state.data?.pages?.length || 0; + // immediately set if there is the first page without throttling other wise throttle + const executeImmediately = pagesCount <= 1; + setHistoryQuery(() => state, executeImmediately); + }); + + // Fetch first page + fetcherRef.current.start((state) => !state?.data?.pages?.length); + + return () => { + unsubscribe(); + }; + }, [setHistoryQuery]); + + useEffect(() => { + return () => { + fetcherRef.current?.destroy(); + }; + }, []); + + const startLoadingHistory = useCallback( + (shouldContinue: ShouldContinueCallback = () => true) => { + if (!fetcherRef.current) return; + fetcherRef.current.start(shouldContinue); + }, + [] + ); + + const stopLoadingHistory = useCallback(() => { + if (!fetcherRef.current) return; + fetcherRef.current.stop(); + }, []); + + const fetchSingleNextPage = useCallback(() => { + if (!fetcherRef.current) return; + fetcherRef.current.fetchSingleNextPage(); + }, []); + + return { + historyQuery, + startLoadingHistory, + stopLoadingHistory, + fetchSingleNextPage, + }; +}