Skip to content

Commit 4b858bb

Browse files
authored
feat: Create hook for fetching history (#1063)
* Create fetcher utility Signed-off-by: Assem Hafez <assem.hafez@uber.com> * rename query Signed-off-by: Assem Hafez <assem.hafez@uber.com> * Create hook for fetching history Signed-off-by: Assem Hafez <assem.hafez@uber.com> * add configurable throttleMs to the hook Signed-off-by: Assem Hafez <assem.hafez@uber.com> * update fetcher based on feedback Signed-off-by: Assem Hafez <assem.hafez@uber.com> * rename unmout to destroy Signed-off-by: Assem Hafez <assem.hafez@uber.com> * update fetcher based on feedback Signed-off-by: Assem Hafez <assem.hafez@uber.com> * rename unmout to destroy Signed-off-by: Assem Hafez <assem.hafez@uber.com> * Create hook for fetching history Signed-off-by: Assem Hafez <assem.hafez@uber.com> * move condition into executeImmediately Signed-off-by: Assem Hafez <assem.hafez@uber.com> * update destroy in method Signed-off-by: Assem Hafez <assem.hafez@uber.com> --------- Signed-off-by: Assem Hafez <assem.hafez@uber.com>
1 parent f55ce2a commit 4b858bb

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { QueryClient } from '@tanstack/react-query';
2+
3+
import { act, renderHook, waitFor } from '@/test-utils/rtl';
4+
5+
import workflowHistoryMultiPageFixture from '../../__fixtures__/workflow-history-multi-page-fixture';
6+
import { workflowPageUrlParams } from '../../__fixtures__/workflow-page-url-params';
7+
import WorkflowHistoryFetcher from '../../helpers/workflow-history-fetcher';
8+
import useWorkflowHistoryFetcher from '../use-workflow-history-fetcher';
9+
10+
jest.mock('../../helpers/workflow-history-fetcher');
11+
12+
const mockParams = {
13+
...workflowPageUrlParams,
14+
pageSize: 50,
15+
waitForNewEvent: true,
16+
};
17+
let mockFetcherInstance: jest.Mocked<WorkflowHistoryFetcher>;
18+
let mockOnChangeCallback: jest.Mock;
19+
let mockUnsubscribe: jest.Mock;
20+
21+
function setup() {
22+
const hookResult = renderHook(() => useWorkflowHistoryFetcher(mockParams));
23+
24+
return {
25+
...hookResult,
26+
mockFetcherInstance,
27+
mockOnChangeCallback,
28+
mockUnsubscribe,
29+
};
30+
}
31+
32+
describe(useWorkflowHistoryFetcher.name, () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
36+
mockOnChangeCallback = jest.fn();
37+
mockUnsubscribe = jest.fn();
38+
39+
mockFetcherInstance = {
40+
start: jest.fn(),
41+
stop: jest.fn(),
42+
destroy: jest.fn(),
43+
fetchSingleNextPage: jest.fn(),
44+
onChange: jest.fn((callback) => {
45+
mockOnChangeCallback.mockImplementation(callback);
46+
return mockUnsubscribe;
47+
}),
48+
getCurrentState: jest.fn(() => ({
49+
data: undefined,
50+
error: null,
51+
isError: false,
52+
isLoading: false,
53+
isPending: true,
54+
isFetchingNextPage: false,
55+
hasNextPage: false,
56+
status: 'pending' as const,
57+
})),
58+
} as unknown as jest.Mocked<WorkflowHistoryFetcher>;
59+
60+
(
61+
WorkflowHistoryFetcher as jest.MockedClass<typeof WorkflowHistoryFetcher>
62+
).mockImplementation(() => mockFetcherInstance);
63+
});
64+
65+
afterEach(() => {
66+
jest.clearAllMocks();
67+
});
68+
69+
it('should create a WorkflowHistoryFetcher instance with correct params', () => {
70+
setup();
71+
72+
expect(WorkflowHistoryFetcher).toHaveBeenCalledWith(
73+
expect.any(QueryClient),
74+
mockParams
75+
);
76+
expect(WorkflowHistoryFetcher).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it('should reuse the same fetcher instance on re-renders', () => {
80+
const { rerender } = setup();
81+
82+
rerender();
83+
rerender();
84+
85+
expect(WorkflowHistoryFetcher).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('should subscribe to fetcher state changes on mount', () => {
89+
setup();
90+
91+
expect(mockFetcherInstance.onChange).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it('should start fetcher to load first page on mount', () => {
95+
setup();
96+
97+
expect(mockFetcherInstance.start).toHaveBeenCalledWith(
98+
expect.any(Function)
99+
);
100+
expect(mockFetcherInstance.start).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('should return initial history query state', () => {
104+
const { result } = setup();
105+
106+
expect(result.current.historyQuery).toBeDefined();
107+
expect(result.current.historyQuery.isPending).toBe(true);
108+
});
109+
110+
it('should update historyQuery when fetcher state changes', async () => {
111+
const { result, mockOnChangeCallback } = setup();
112+
113+
const newState = {
114+
data: {
115+
pages: [workflowHistoryMultiPageFixture[0]],
116+
pageParams: [],
117+
},
118+
error: null,
119+
isError: false,
120+
isLoading: false,
121+
isPending: false,
122+
isFetchingNextPage: false,
123+
hasNextPage: true,
124+
status: 'success' as const,
125+
};
126+
127+
act(() => {
128+
mockOnChangeCallback(newState);
129+
});
130+
131+
await waitFor(() => {
132+
expect(result.current.historyQuery.status).toBe('success');
133+
});
134+
});
135+
136+
it('should call fetcher.start() with custom shouldContinue callback passed to startLoadingHistory', () => {
137+
const { result, mockFetcherInstance } = setup();
138+
const customShouldContinue = jest.fn(() => false);
139+
140+
act(() => {
141+
result.current.startLoadingHistory(customShouldContinue);
142+
});
143+
144+
expect(mockFetcherInstance.start).toHaveBeenCalledWith(
145+
customShouldContinue
146+
);
147+
});
148+
149+
it('should call fetcher.stop() within stopLoadingHistory', () => {
150+
const { result, mockFetcherInstance } = setup();
151+
152+
act(() => {
153+
result.current.stopLoadingHistory();
154+
});
155+
156+
expect(mockFetcherInstance.stop).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it('should call fetcher.fetchSingleNextPage() within fetchSingleNextPage', () => {
160+
const { result, mockFetcherInstance } = setup();
161+
162+
act(() => {
163+
result.current.fetchSingleNextPage();
164+
});
165+
166+
expect(mockFetcherInstance.fetchSingleNextPage).toHaveBeenCalledTimes(1);
167+
});
168+
169+
it('should unsubscribe from onChange when unmounted', () => {
170+
const { unmount, mockUnsubscribe } = setup();
171+
172+
unmount();
173+
174+
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
175+
});
176+
177+
it('should call fetcher.unmount() when component unmounts', () => {
178+
const { unmount, mockFetcherInstance } = setup();
179+
180+
unmount();
181+
182+
expect(mockFetcherInstance.destroy).toHaveBeenCalledTimes(1);
183+
});
184+
185+
it('should return all expected methods and state', () => {
186+
const { result } = setup();
187+
188+
expect(result.current).toHaveProperty('historyQuery');
189+
expect(result.current).toHaveProperty('startLoadingHistory');
190+
expect(result.current).toHaveProperty('stopLoadingHistory');
191+
expect(result.current).toHaveProperty('fetchSingleNextPage');
192+
expect(typeof result.current.startLoadingHistory).toBe('function');
193+
expect(typeof result.current.stopLoadingHistory).toBe('function');
194+
expect(typeof result.current.fetchSingleNextPage).toBe('function');
195+
});
196+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
import {
4+
type InfiniteData,
5+
type InfiniteQueryObserverResult,
6+
useQueryClient,
7+
} from '@tanstack/react-query';
8+
9+
import useThrottledState from '@/hooks/use-throttled-state';
10+
import {
11+
type WorkflowHistoryQueryParams,
12+
type GetWorkflowHistoryResponse,
13+
type RouteParams,
14+
} from '@/route-handlers/get-workflow-history/get-workflow-history.types';
15+
import { type RequestError } from '@/utils/request/request-error';
16+
17+
import WorkflowHistoryFetcher from '../helpers/workflow-history-fetcher';
18+
import { type ShouldContinueCallback } from '../helpers/workflow-history-fetcher.types';
19+
20+
export default function useWorkflowHistoryFetcher(
21+
params: WorkflowHistoryQueryParams & RouteParams,
22+
throttleMs: number = 2000
23+
) {
24+
const queryClient = useQueryClient();
25+
const fetcherRef = useRef<WorkflowHistoryFetcher | null>(null);
26+
27+
if (!fetcherRef.current) {
28+
fetcherRef.current = new WorkflowHistoryFetcher(queryClient, params);
29+
}
30+
31+
const [historyQuery, setHistoryQuery] = useThrottledState<
32+
InfiniteQueryObserverResult<
33+
InfiniteData<GetWorkflowHistoryResponse>,
34+
RequestError
35+
>
36+
>(fetcherRef.current.getCurrentState(), throttleMs, {
37+
leading: true,
38+
trailing: true,
39+
});
40+
41+
useEffect(() => {
42+
if (!fetcherRef.current) return;
43+
44+
const unsubscribe = fetcherRef.current.onChange((state) => {
45+
const pagesCount = state.data?.pages?.length || 0;
46+
// immediately set if there is the first page without throttling other wise throttle
47+
const executeImmediately = pagesCount <= 1;
48+
setHistoryQuery(() => state, executeImmediately);
49+
});
50+
51+
// Fetch first page
52+
fetcherRef.current.start((state) => !state?.data?.pages?.length);
53+
54+
return () => {
55+
unsubscribe();
56+
};
57+
}, [setHistoryQuery]);
58+
59+
useEffect(() => {
60+
return () => {
61+
fetcherRef.current?.destroy();
62+
};
63+
}, []);
64+
65+
const startLoadingHistory = useCallback(
66+
(shouldContinue: ShouldContinueCallback = () => true) => {
67+
if (!fetcherRef.current) return;
68+
fetcherRef.current.start(shouldContinue);
69+
},
70+
[]
71+
);
72+
73+
const stopLoadingHistory = useCallback(() => {
74+
if (!fetcherRef.current) return;
75+
fetcherRef.current.stop();
76+
}, []);
77+
78+
const fetchSingleNextPage = useCallback(() => {
79+
if (!fetcherRef.current) return;
80+
fetcherRef.current.fetchSingleNextPage();
81+
}, []);
82+
83+
return {
84+
historyQuery,
85+
startLoadingHistory,
86+
stopLoadingHistory,
87+
fetchSingleNextPage,
88+
};
89+
}

0 commit comments

Comments
 (0)