From c2d6668aaac5ee1a5862821f902fbe58d58888e7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 18 Sep 2024 10:44:20 +0200 Subject: [PATCH] Add usePolledAPIFetch hook to do API polling (#6683) --- lms/static/scripts/frontend_apps/utils/api.ts | 71 ++++++++++++ .../frontend_apps/utils/test/api-test.js | 101 +++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/lms/static/scripts/frontend_apps/utils/api.ts b/lms/static/scripts/frontend_apps/utils/api.ts index ad34793992..8a7a75577d 100644 --- a/lms/static/scripts/frontend_apps/utils/api.ts +++ b/lms/static/scripts/frontend_apps/utils/api.ts @@ -1,3 +1,4 @@ +import { useStableCallback } from '@hypothesis/frontend-shared'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import type { Pagination } from '../api-types'; @@ -214,6 +215,76 @@ export function useAPIFetch( return useFetch(path ? `${path}${paramStr}` : null, fetcher); } +export type PolledAPIFetchOptions = { + /** Path for API call */ + path: string; + /** Query params for API call */ + params?: QueryParams; + /** Determines if, based on the result, the API should be called again */ + shouldRefresh: (result: FetchResult) => boolean; + + /** + * Amount of ms after which a refresh should happen, if shouldRefresh() + * returns true. + * Defaults to 500ms. + */ + refreshAfter?: number; + + /** Test seam */ + _setTimeout?: typeof setTimeout; + /** Test seam */ + _clearTimeout?: typeof clearTimeout; +}; + +/** + * Hook that fetches data using authenticated API requests. + * + * This is a variant of {@link useAPIFetch} that supports automatically + * refreshing results at an interval until some condition is met. + * This is useful for example when calling an API that reports the status of a + * long-running operation. + */ +export function usePolledAPIFetch({ + path, + params, + shouldRefresh: unstableShouldRefresh, + refreshAfter = 500, + /* istanbul ignore next - test seam */ + _setTimeout = setTimeout, + /* istanbul ignore next - test seam */ + _clearTimeout = clearTimeout, +}: PolledAPIFetchOptions): FetchResult { + const result = useAPIFetch(path, params); + const shouldRefresh = useStableCallback(unstableShouldRefresh); + + const timeout = useRef | null>(null); + const resetTimeout = useCallback(() => { + if (timeout.current) { + _clearTimeout(timeout.current); + } + timeout.current = null; + }, [_clearTimeout]); + + useEffect(() => { + if (result.isLoading) { + return () => {}; + } + + // Once we finish loading, schedule a retry if the request should be + // refreshed + if (shouldRefresh(result)) { + timeout.current = _setTimeout(() => { + result.retry(); + timeout.current = null; + }, refreshAfter); + } + + return resetTimeout; + }, [_setTimeout, refreshAfter, resetTimeout, result, shouldRefresh]); + + return result; +} + export type PaginatedFetchResult = Omit, 'mutate'> & { /** Determines if currently loading the first page */ isLoadingFirstPage: boolean; diff --git a/lms/static/scripts/frontend_apps/utils/test/api-test.js b/lms/static/scripts/frontend_apps/utils/test/api-test.js index e1971d9136..a7c998f8b7 100644 --- a/lms/static/scripts/frontend_apps/utils/test/api-test.js +++ b/lms/static/scripts/frontend_apps/utils/test/api-test.js @@ -1,4 +1,4 @@ -import { waitFor } from '@hypothesis/frontend-testing'; +import { delay, waitFor } from '@hypothesis/frontend-testing'; import { mount } from 'enzyme'; import { Config } from '../../config'; @@ -9,6 +9,7 @@ import { useAPIFetch, $imports, usePaginatedAPIFetch, + usePolledAPIFetch, } from '../api'; function createResponse(status, body) { @@ -651,3 +652,101 @@ describe('usePaginatedAPIFetch', () => { assert.equal(getMainContent(wrapper), 'No content'); }); }); + +describe('usePolledAPIFetch', () => { + let fakeUseFetch; + let fakeRetry; + let fakeClearTimeout; + + function mockFetchFinished() { + fakeUseFetch.returns({ + data: {}, + isLoading: false, + retry: fakeRetry, + }); + } + + function mockLoadingState() { + fakeUseFetch.returns({ + data: null, + isLoading: true, + }); + } + + beforeEach(() => { + fakeUseFetch = sinon.stub(); + mockLoadingState(); + + fakeRetry = sinon.stub(); + fakeClearTimeout = sinon.stub(); + + const fakeUseConfig = sinon.stub(); + fakeUseConfig.returns({ + api: { authToken: 'some-token' }, + }); + + $imports.$mock({ + '../config': { useConfig: fakeUseConfig }, + './fetch': { useFetch: fakeUseFetch }, + }); + }); + + afterEach(() => { + $imports.$restore(); + }); + + function TestWidget({ shouldRefresh }) { + const result = usePolledAPIFetch({ + path: '/api/some/path', + shouldRefresh, + + // Keep asynchronous nature of mocked setTimeout, but with a virtually + // immediate execution of the callback + _setTimeout: callback => setTimeout(callback), + _clearTimeout: fakeClearTimeout, + }); + + return ( +
+ {result.isLoading && 'Loading'} + {result.data && 'Loaded'} +
+ ); + } + + function createComponent(shouldRefresh) { + return mount(); + } + + it('does not refresh while loading is in progress', async () => { + const shouldRefresh = sinon.stub().returns(true); + createComponent(shouldRefresh); + + assert.notCalled(shouldRefresh); + }); + + it('refreshes requests until shouldRefresh returns false', async () => { + mockFetchFinished(); + + const shouldRefresh = sinon.stub().returns(true); + createComponent(shouldRefresh); + + assert.called(shouldRefresh); + + // Retry should be called once timeout ends + assert.notCalled(fakeRetry); + await delay(1); + assert.calledOnce(fakeRetry); + }); + + it('clears pending timeout when component is unmounted', () => { + mockFetchFinished(); + + const shouldRefresh = sinon.stub().returns(true); + const wrapper = createComponent(shouldRefresh); + + wrapper.unmount(); + + assert.called(fakeClearTimeout); + }); +});