diff --git a/README.md b/README.md index 220ec70e..9318a62a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The goal of this project is to provide a React with TypeScript starter applicati - Code Formatting: [Prettier](https://prettier.io/) - End-to-End (E2E) Testing: [Cypress](https://www.cypress.io/) - Accessibility Testing: [cypress-axe](https://www.npmjs.com/package/cypress-axe) -- API support: [axios](https://axios-http.com/) +- API support: [Axios](https://axios-http.com/) with [React Query](https://tanstack.com/query/v3/) - Authentication support: [Keycloak](https://www.keycloak.org/) ## Table of Contents diff --git a/src/App.tsx b/src/App.tsx index 0af9fc53..7283cc96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { Route, Routes } from 'react-router'; -import { RecoilRoot } from 'recoil'; import { Footer } from './components/footer/footer'; import { Header } from './components/header/header'; import { ProtectedRoute } from './components/protected-route/protected-route'; @@ -9,8 +9,10 @@ import Details from './pages/details/details'; import { Home } from './pages/home/home'; import { SignIn } from './pages/sign-in/sign-in'; +const queryClient = new QueryClient(); + export const App = (): React.ReactElement => ( - +
@@ -27,5 +29,5 @@ export const App = (): React.ReactElement => (
-
+ ); diff --git a/src/hooks/use-api.test.ts b/src/hooks/use-api.test.ts deleted file mode 100644 index f681d55c..00000000 --- a/src/hooks/use-api.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import axios from '@src/utils/axios'; -import { act, renderHook } from '@testing-library/react'; -import MockAdapter from 'axios-mock-adapter'; -import { RecoilRoot } from 'recoil'; -import { launchData } from '../data/launch'; -import useApi from './use-api'; - -describe('useApi', () => { - const mock = new MockAdapter(axios); - beforeAll(() => { - mock.reset(); - }); - - test('should call getItems successfully', async () => { - mock.onGet().reply(200, { results: [] }); - const { result } = renderHook(() => useApi(), { - wrapper: RecoilRoot, - }); - - await act(async () => { - result.current.getItems(); - }); - expect(result.current.getItems).toBeTruthy(); - }); - - test('should call getItems with mock data', async () => { - mock.onGet().reply(200, { results: launchData }); - const { result } = renderHook(() => useApi(), { - wrapper: RecoilRoot, - }); - - await act(async () => { - result.current.getItems(); - }); - expect(result.current.getItems).toBeTruthy(); - }); - - test('should call getItems with error', async () => { - mock.onGet().reply(500, { error: 'error' }); - const { result } = renderHook(() => useApi(), { - wrapper: RecoilRoot, - }); - - await act(async () => { - result.current.getItems(); - }); - expect(result.current.getItems).toBeTruthy(); - }); - - test('should call getItem successfully', async () => { - mock.onGet().reply(200, null); - const { result } = renderHook(() => useApi(), { - wrapper: RecoilRoot, - }); - - await act(async () => { - result.current.getItem('1'); - }); - expect(result.current.getItem).toBeTruthy(); - }); - - test('should call getItem with mock data', async () => { - mock.onGet().reply(200, launchData[0]); - const { result } = renderHook(() => useApi(), { - wrapper: RecoilRoot, - }); - - await act(async () => { - result.current.getItem('1'); - }); - expect(result.current.getItem).toBeTruthy(); - }); - - test('should call getItem with error', async () => { - mock.onGet().reply(500, { error: 'error' }); - const { result } = renderHook(() => useApi(), { - wrapper: RecoilRoot, - }); - - await act(async () => { - result.current.getItem('1'); - }); - expect(result.current.getItem).toBeTruthy(); - }); -}); diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts deleted file mode 100644 index 01e47732..00000000 --- a/src/hooks/use-api.ts +++ /dev/null @@ -1,57 +0,0 @@ -import axios from '@src/utils/axios'; -import { useCallback, useState } from 'react'; -import { Launch } from '../types/launch'; - -const useApi = () => { - const [loading, setLoading] = useState(false); - const [items, setItems] = useState(); - const [item, setItem] = useState(); - const [error, setError] = useState(null); - - const getItems = useCallback((): void => { - setLoading(true); - axios - .get(`/?format=json`) - .then((response) => { - return response.data; - }) - .then((data) => { - setItems(data.results); - }) - .catch((error) => { - setError(error.message); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - const getItem = useCallback((id: string): void => { - setLoading(true); - axios - .get(`/${id}/?format=json`) - .then((response) => { - return response.data; - }) - .then((data) => { - setItem(data); - }) - .catch((error) => { - setError(error.message); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - return { - loading, - items, - item, - error, - getItems, - getItem, - }; -}; - -export default useApi; diff --git a/src/main.tsx b/src/main.tsx index 8e4243d0..d23eb050 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { AuthProvider } from 'react-oidc-context'; import { BrowserRouter } from 'react-router-dom'; +import { RecoilRoot } from 'recoil'; import { App } from './App.tsx'; import './styles.scss'; import keycloak from './utils/keycloak.ts'; @@ -10,7 +11,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , diff --git a/src/pages/dashboard/dashboard.test.tsx b/src/pages/dashboard/dashboard.test.tsx index 9ae9e22b..f5a6a1a0 100644 --- a/src/pages/dashboard/dashboard.test.tsx +++ b/src/pages/dashboard/dashboard.test.tsx @@ -1,4 +1,5 @@ import axios from '@src/utils/axios'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { AuthProvider } from 'react-oidc-context'; @@ -9,11 +10,14 @@ import { User } from '../../types/user'; import { Dashboard } from './dashboard'; describe('Dashboard', () => { + const queryClient = new QueryClient(); const componentWrapper = ( - + + + @@ -22,20 +26,19 @@ describe('Dashboard', () => { const mock = new MockAdapter(axios); beforeAll(() => { mock.reset(); + queryClient.setDefaultOptions({ + queries: { + retry: false, // Disable retries for tests + }, + }); }); - test('should render successfully', async () => { - const { baseElement } = render(componentWrapper); - await act(async () => { - expect(baseElement).toBeTruthy(); - expect(baseElement.querySelector('h1')?.textContent).toEqual( - 'My Dashboard', - ); - }); + beforeEach(() => { + queryClient.clear(); }); - test('should render with mock data', async () => { - mock.onGet().reply(200, { results: [] }); + test('should render successfully', async () => { + mock.onGet(new RegExp('/?format=json')).reply(200, { results: [] }); jest.spyOn(useAuthMock, 'default').mockReturnValue({ isSignedIn: true, currentUserData: {} as User, @@ -59,7 +62,7 @@ describe('Dashboard', () => { }); test('should render with error', async () => { - mock.onGet().networkError(); + mock.onGet(new RegExp('/?format=json')).networkError(); const { baseElement } = render(componentWrapper); await act(async () => { expect(baseElement).toBeTruthy(); diff --git a/src/pages/dashboard/dashboard.tsx b/src/pages/dashboard/dashboard.tsx index 584f26e4..fc19eb10 100644 --- a/src/pages/dashboard/dashboard.tsx +++ b/src/pages/dashboard/dashboard.tsx @@ -1,8 +1,10 @@ import { Spinner } from '@metrostar/comet-extras'; import { Card, CardBody } from '@metrostar/comet-uswds'; -import React, { useEffect } from 'react'; +import { Launch } from '@src/types/launch'; +import axios from '@src/utils/axios'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; import ErrorNotification from '../../components/error-notification/error-notification'; -import useApi from '../../hooks/use-api'; import useAuth from '../../hooks/use-auth'; import { DashboardBarChart } from './dashboard-bar-chart/dashboard-bar-chart'; import { DashboardPieChart } from './dashboard-pie-chart/dashboard-pie-chart'; @@ -10,13 +12,21 @@ import { DashboardTable } from './dashboard-table/dashboard-table'; export const Dashboard = (): React.ReactElement => { const { isSignedIn } = useAuth(); - const { getItems, items, loading, error } = useApi(); - - useEffect(() => { - if (isSignedIn) { - getItems(); - } - }, [getItems, isSignedIn]); + const { + isLoading, + error, + data: items, + } = useQuery({ + queryKey: ['launches'], + queryFn: () => + axios + .get('/?format=json') + .then((response) => { + return response.data; + }) + .then((data) => data.results), + enabled: isSignedIn, + }); return (
@@ -28,7 +38,7 @@ export const Dashboard = (): React.ReactElement => { {error && (
- +
)} @@ -52,7 +62,7 @@ export const Dashboard = (): React.ReactElement => {
- {loading ? ( + {isLoading ? ( ) : ( diff --git a/src/pages/details/details.test.tsx b/src/pages/details/details.test.tsx index 1a278679..622c5680 100644 --- a/src/pages/details/details.test.tsx +++ b/src/pages/details/details.test.tsx @@ -1,4 +1,5 @@ import axios from '@src/utils/axios'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { AuthProvider } from 'react-oidc-context'; @@ -15,11 +16,14 @@ jest.mock('react-router-dom', () => ({ })); describe('Details', () => { + const queryClient = new QueryClient(); const componentWrapper = ( -
+ +
+ @@ -28,17 +32,19 @@ describe('Details', () => { const mock = new MockAdapter(axios); beforeAll(() => { mock.reset(); + queryClient.setDefaultOptions({ + queries: { + retry: false, // Disable retries for tests + }, + }); }); - test('should render successfully', async () => { - const { baseElement } = render(componentWrapper); - await act(async () => { - expect(baseElement).toBeTruthy(); - }); + beforeEach(() => { + queryClient.clear(); }); - test('should render with mock data', async () => { - mock.onGet().reply(200, launchData[0]); + test('should render successfully', async () => { + mock.onGet(new RegExp('/*/?format=json')).reply(200, launchData[0]); jest.spyOn(useAuthMock, 'default').mockReturnValue({ isSignedIn: true, currentUserData: {} as User, @@ -57,7 +63,7 @@ describe('Details', () => { }); test('should render with error', async () => { - mock.onGet().networkError(); + mock.onGet(new RegExp('/*/?format=json')).networkError(); const { baseElement } = render(componentWrapper); await act(async () => { expect(baseElement).toBeTruthy(); diff --git a/src/pages/details/details.tsx b/src/pages/details/details.tsx index e7da8037..9ac5f1d5 100644 --- a/src/pages/details/details.tsx +++ b/src/pages/details/details.tsx @@ -1,29 +1,24 @@ import { Spinner } from '@metrostar/comet-extras'; import { Card } from '@metrostar/comet-uswds'; -import React, { useEffect, useState } from 'react'; +import axios from '@src/utils/axios'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; import { useParams } from 'react-router-dom'; import ErrorNotification from '../../components/error-notification/error-notification'; -import useApi from '../../hooks/use-api'; import useAuth from '../../hooks/use-auth'; import { Launch } from '../../types/launch'; export const Details = (): React.ReactElement => { const { id } = useParams(); const { isSignedIn } = useAuth(); - const { getItem, item, loading, error } = useApi(); - const [data, setData] = useState(null); - - useEffect(() => { - if (isSignedIn && id) { - getItem(id); - } - }, [isSignedIn, id, getItem]); - - useEffect(() => { - if (item) { - setData(item); - } - }, [item]); + const { isLoading, error, data } = useQuery({ + queryKey: ['launches', id], + queryFn: () => + axios.get(`/${id}/?format=json`).then((response) => { + return response.data; + }), + enabled: isSignedIn && !!id, + }); return (
@@ -36,13 +31,13 @@ export const Details = (): React.ReactElement => { {error && (
- +
)}
- {loading ? ( + {isLoading ? (