-
Notifications
You must be signed in to change notification settings - Fork 124
feat: Create hook for fetching history #1063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bbe7715
6b09945
2a8c835
1b5796a
6af4ebc
ae114ec
b9e288b
492e54d
83e390d
6d37203
a08af6d
0ab4e02
6e551d0
7901d5a
e6159d7
7584644
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<WorkflowHistoryFetcher>; | ||
| 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>; | ||
|
|
||
| ( | ||
| WorkflowHistoryFetcher as jest.MockedClass<typeof WorkflowHistoryFetcher> | ||
| ).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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<WorkflowHistoryFetcher | null>(null); | ||
|
|
||
| if (!fetcherRef.current) { | ||
| fetcherRef.current = new WorkflowHistoryFetcher(queryClient, params); | ||
| } | ||
|
|
||
| const [historyQuery, setHistoryQuery] = useThrottledState< | ||
| InfiniteQueryObserverResult< | ||
| InfiniteData<GetWorkflowHistoryResponse>, | ||
| 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); | ||
|
Comment on lines
+51
to
+52
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder why we fetch the first page explicitly rather than relying on the flywheel / fetch-loop?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both works, in this case i went for being more explicit about loading the first page separately from the conditions for
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Btw |
||
|
|
||
| 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, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this file included in this PR because it's stacked on the prior PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, unfortunately both exists in this PR as they are dependant.