Przeglądaj źródła

feat: request async callback hook

Fela Maslen 5 lat temu
rodzic
commit
5a156bb8d4
2 zmienionych plików z 162 dodań i 1 usunięć
  1. 103 0
      gmus-web/src/hooks/request.spec.tsx
  2. 59 1
      gmus-web/src/hooks/request.ts

+ 103 - 0
gmus-web/src/hooks/request.spec.tsx

@@ -0,0 +1,103 @@
+import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react';
+import { AxiosInstance, AxiosResponse } from 'axios';
+import nock from 'nock';
+import React from 'react';
+
+import { useRequestCallback } from './request';
+
+describe(useRequestCallback.name, () => {
+  type MyQuery = { something: string };
+  type MyResponse = { result: number };
+
+  const onError = jest.fn();
+  const sendRequest = (axios: AxiosInstance, query: MyQuery): Promise<AxiosResponse<MyResponse>> =>
+    axios.get(`http://my-api.url:1234/my/request?something=${query.something}`);
+
+  const TestComponent: React.FC = () => {
+    const [onRequest, response, loading] = useRequestCallback<MyQuery, MyResponse>({
+      onError,
+      sendRequest,
+    });
+
+    return (
+      <>
+        <button onClick={(): void => onRequest({ something: 'yes' })}>Send request!</button>
+        <div data-testid="response">{JSON.stringify(response)}</div>
+        <div data-testid="loading">{loading ? 'Loading' : 'Not loading'}</div>
+      </>
+    );
+  };
+
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  const setupRequest = (): RenderResult => {
+    const renderResult = render(<TestComponent />);
+    act(() => {
+      fireEvent.click(renderResult.getByText('Send request!'));
+    });
+    return renderResult;
+  };
+
+  it('should return null as the initial response', () => {
+    expect.assertions(1);
+    const { getByTestId } = render(<TestComponent />);
+    expect(JSON.parse(getByTestId('response').innerHTML)).toBeNull();
+  });
+
+  it('should initially set the loading state to false', () => {
+    expect.assertions(1);
+    const { getByTestId } = render(<TestComponent />);
+    expect(getByTestId('loading')).toHaveTextContent('Not loading');
+  });
+
+  describe('when sending a request', () => {
+    beforeEach(() => {
+      nock('http://my-api.url:1234')
+        .get('/my/request?something=yes')
+        .reply(200, { result: 125 }, { 'Access-Control-Allow-Origin': '*' });
+    });
+
+    it('should set the loading state to true', async () => {
+      expect.assertions(1);
+      const { getByTestId, unmount } = setupRequest();
+      expect(getByTestId('loading')).toHaveTextContent('Loading');
+      unmount();
+    });
+
+    it('should set the response and loading state back to false', async () => {
+      expect.assertions(3);
+
+      const { getByTestId, unmount } = setupRequest();
+
+      await waitFor(() => {
+        expect(getByTestId('loading')).toHaveTextContent('Not loading');
+      });
+
+      expect(JSON.parse(getByTestId('response').innerHTML)).toStrictEqual({ result: 125 });
+      unmount();
+    });
+  });
+
+  describe('when an error occurs', () => {
+    beforeEach(() => {
+      nock('http://my-api.url:1234')
+        .get('/my/request?something=yes')
+        .reply(500, 'Some error occurred', { 'Access-Control-Allow-Origin': '*' });
+
+      onError.mockClear();
+    });
+
+    it('should call onError', async () => {
+      expect.assertions(3);
+      const { unmount } = setupRequest();
+
+      await waitFor(() => {
+        expect(onError).toHaveBeenCalledTimes(1);
+        expect(onError).toHaveBeenCalledWith(new Error('Request failed with status code 500'));
+      });
+      unmount();
+    });
+  });
+});

+ 59 - 1
gmus-web/src/hooks/request.ts

@@ -1,5 +1,5 @@
 import axios, { Canceler, AxiosInstance, AxiosResponse } from 'axios';
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 
 type Options<Query, Response> = {
   query: Query;
@@ -70,3 +70,61 @@ export function useCancellableRequest<Query, Response = void>({
 
   return loading;
 }
+
+type CallbackOptions<Query, Response> = Pick<Options<Query, Response>, 'sendRequest' | 'onError'>;
+
+export function useRequestCallback<Query, Response = void>({
+  onError,
+  sendRequest,
+}: CallbackOptions<Query, Response>): [(query: Query) => void, Response | null, boolean] {
+  const [response, setResponse] = useState<Response | null>(null);
+  const [loading, setLoading] = useState<boolean>(false);
+
+  const cancel = useRef<() => void>();
+
+  useEffect(() => (): void => cancel.current?.(), []);
+
+  const onRequest = useCallback(
+    (query: Query) => {
+      let cancelled = false;
+
+      let cancelRequest: Canceler | null = null;
+
+      cancel.current?.();
+      cancel.current = (): void => {
+        cancelled = true;
+        cancelRequest?.();
+      };
+
+      const axiosWithToken = axios.create({
+        cancelToken: new axios.CancelToken((token): void => {
+          cancelRequest = token;
+        }),
+      });
+
+      const makeRequest = async (): Promise<void> => {
+        try {
+          setLoading(true);
+          const res = await sendRequest(axiosWithToken, query);
+
+          if (!cancelled) {
+            setResponse(res.data);
+          }
+        } catch (err) {
+          if (!axios.isCancel(err)) {
+            onError?.(err);
+          }
+        } finally {
+          if (!cancelled) {
+            setLoading(false);
+          }
+        }
+      };
+
+      makeRequest();
+    },
+    [onError, sendRequest],
+  );
+
+  return [onRequest, response, loading];
+}