implemented request cancellation on component dismount

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Raul Kele 2022-10-11 14:38:00 +03:00
parent dce87afba9
commit d1bf977871
25 changed files with 117 additions and 70 deletions

View File

@ -16,7 +16,6 @@ function App() {
return localStorageToken ? true : false;
};
const [data, setData] = useState(null);
const [isAuthEnabled, setIsAuthEnabled] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(isToken());
@ -26,7 +25,7 @@ function App() {
<Routes>
<Route element={<AuthWrapper isLoggedIn={isLoggedIn} redirect="/login" />}>
<Route path="/" element={<Navigate to="/home" />} />
<Route path="/home" element={<HomePage data={data} updateData={setData} />} />
<Route path="/home" element={<HomePage />} />
<Route path="/explore" element={<ExplorePage />} />
<Route path="/image/:name" element={<RepoPage />} />
<Route path="/image/:name/tag/:tag" element={<TagPage />} />

View File

@ -77,6 +77,13 @@ describe('Explore component', () => {
expect(await screen.findByText(/nodeUnique/i)).toBeInTheDocument();
});
it('displays the no data message if no data is received', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { GlobalSearch: { Repos: [] } } } });
render(<StateExploreWrapper />);
expect(await screen.findByText(/Looks like/i)).toBeInTheDocument();
});
it("should log an error when data can't be fetched", async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });

View File

@ -15,7 +15,7 @@ it('renders the explore page component', () => {
render(
<BrowserRouter>
<Routes>
<Route path="*" element={<ExplorePage data={[]} updateData={() => {}} />} />
<Route path="*" element={<ExplorePage />} />
</Routes>
</BrowserRouter>
);

View File

@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import Home from 'components/Home';
import React, { useState } from 'react';
import React from 'react';
// useNavigate mock
const mockedUsedNavigate = jest.fn();
@ -11,10 +11,6 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate
}));
const StateHomeWrapper = () => {
const [data, useData] = useState([]);
return <Home data={data} updateData={useData} />;
};
const mockImageList = {
RepoListWithNewestImage: [
{
@ -55,6 +51,11 @@ const mockImageList = {
}
]
};
beforeEach(() => {
window.scrollTo = jest.fn();
});
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
@ -64,7 +65,7 @@ describe('Home component', () => {
it('fetches image data and renders popular, bookmarks and recently updated', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<StateHomeWrapper />);
render(<Home />);
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(2));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(2));
await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1));
@ -74,7 +75,7 @@ describe('Home component', () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<StateHomeWrapper />);
render(<Home />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
});

View File

@ -15,7 +15,7 @@ it('renders the homepage component', () => {
render(
<BrowserRouter>
<Routes>
<Route path="*" element={<HomePage data={[]} updateData={() => {}} />} />
<Route path="*" element={<HomePage />} />
</Routes>
</BrowserRouter>
);

View File

@ -38,7 +38,7 @@ describe('Sign in form', () => {
beforeEach(() => {
// mock auth check request
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 401, data: {} });
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
});
it('should change username and password values on user input', async () => {
@ -53,16 +53,16 @@ describe('Sign in form', () => {
it('should display error if username and password values are empty after change', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = screen.getByLabelText(/^Username/i);
const passwordInput = screen.getByLabelText(/^Enter Password/i);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
userEvent.click(usernameInput);
userEvent.type(usernameInput, 't');
userEvent.type(usernameInput, '{backspace}');
userEvent.click(passwordInput);
userEvent.type(passwordInput, 't');
userEvent.type(passwordInput, '{backspace}');
const usernameError = screen.getByText(/enter a username/i);
const passwordError = screen.getByText(/enter a password/i);
const usernameError = await screen.findByText(/enter a username/i);
const passwordError = await screen.findByText(/enter a password/i);
await waitFor(() => expect(usernameError).toBeInTheDocument());
await waitFor(() => expect(passwordError).toBeInTheDocument());
});

View File

@ -71,9 +71,9 @@ describe('Repo details component', () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockRepoDetailsData } });
render(<RepoDetails />);
expect(screen.getByTestId('overview-container')).toBeInTheDocument();
expect(await screen.findByTestId('overview-container')).toBeInTheDocument();
fireEvent.click(await screen.findByText(/tags/i));
expect(screen.getByTestId('tags-container')).toBeInTheDocument();
expect(await screen.findByTestId('tags-container')).toBeInTheDocument();
expect(screen.queryByTestId('overview-container')).not.toBeInTheDocument();
});
@ -81,7 +81,7 @@ describe('Repo details component', () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockRepoDetailsData } });
render(<RepoDetails />);
fireEvent.click(screen.getByTestId('pullcopy-btn'));
fireEvent.click(await screen.findByTestId('pullcopy-btn'));
await waitFor(() => expect(mockCopyToClipboard).toHaveBeenCalledWith('Pull test'));
});
});

View File

@ -26,36 +26,36 @@ const api = {
};
},
get(urli, cfg) {
if (isEmpty(cfg)) {
return axios.get(urli, this.getRequestCfg());
} else {
return axios.get(urli, cfg);
get(urli, abortSignal, cfg) {
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
config = { ...config, signal: abortSignal };
}
return axios.get(urli, config);
},
// This method creates the POST request with axios
// If caller specifies the request configuration to be sent (@param cfg), it adds it to the request
// If caller doesn't specfiy the request configuration, it adds the default config to the request
// This allows caller to pass in any desired request configuration, based on the specifc need
post(urli, payload, cfg) {
// generic post - generate config for request
if (isEmpty(cfg)) {
return axios.post(urli, payload, this.getRequestCfg());
// custom post - use passed in config
// TODO:: validate config object before sending request
} else {
return axios.post(urli, payload, cfg);
post(urli, payload, abortSignal, cfg) {
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
config = { ...config, signal: abortSignal };
}
return axios.post(urli, payload, config);
},
put(urli, payload) {
return axios.put(urli, payload, this.getRequestCfg());
put(urli, payload, abortSignal, cfg) {
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
config = { ...config, signal: abortSignal };
}
return axios.put(urli, payload, config);
},
delete(urli, cfg) {
let requestCfg = isEmpty(cfg) ? this.getRequestCfg() : cfg;
return axios.delete(urli, requestCfg);
delete(urli, abortSignal, cfg) {
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
config = { ...config, signal: abortSignal };
}
return axios.delete(urli, config);
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
// utility
import { api, endpoints } from '../api';
@ -71,11 +71,12 @@ function DependsOn(props) {
const { name } = props;
const classes = useStyles();
const [isLoading, setIsLoading] = useState(true);
const abortController = useMemo(() => new AbortController(), []);
useEffect(() => {
setIsLoading(true);
api
.get(`${host()}${endpoints.dependsOnForImage(name)}`)
.get(`${host()}${endpoints.dependsOnForImage(name)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let images = response.data.data.BaseImageList;
@ -87,6 +88,9 @@ function DependsOn(props) {
console.error(e);
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, []);
const renderDependencies = () => {

View File

@ -1,5 +1,5 @@
// react global
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
// components
import RepoCard from './RepoCard.jsx';
@ -58,6 +58,7 @@ function Explore() {
const [imageFilters, setImageFilters] = useState(false);
const [osFilters, setOSFilters] = useState('');
const [archFilters, setArchFilters] = useState('');
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
const buildFilterQuery = () => {
@ -74,7 +75,10 @@ function Explore() {
useEffect(() => {
setIsLoading(true);
api
.get(`${host()}${endpoints.globalSearch({ searchQuery: search, filter: buildFilterQuery() })}`)
.get(
`${host()}${endpoints.globalSearch({ searchQuery: search, filter: buildFilterQuery() })}`,
abortController.signal
)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.GlobalSearch.Repos;
@ -88,6 +92,9 @@ function Explore() {
.catch((e) => {
console.error(e);
});
return () => {
abortController.abort();
};
}, [search, queryParams, imageFilters, osFilters, archFilters]);
const renderRepoCards = () => {

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import transform from 'utilities/transform';
// utility
@ -118,6 +118,7 @@ function HistoryLayers(props) {
const [historyData, setHistoryData] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoaded, setIsLoaded] = useState(false);
const abortController = useMemo(() => new AbortController(), []);
const { name, history } = props;
useEffect(() => {
@ -126,7 +127,7 @@ function HistoryLayers(props) {
setIsLoaded(true);
} else {
api
.get(`${host()}${endpoints.layersDetailsForImage(name)}`)
.get(`${host()}${endpoints.layersDetailsForImage(name)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let layersHistory = response.data.data.Image;
@ -140,6 +141,9 @@ function HistoryLayers(props) {
setIsLoaded(false);
});
}
return () => {
abortController.abort();
};
}, [name]);
const renderHistoryData = () => {

View File

@ -2,7 +2,7 @@ import { Grid, Stack, Typography } from '@mui/material';
import { makeStyles } from '@mui/styles';
import { api, endpoints } from 'api';
import { host } from '../host';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import PreviewCard from './PreviewCard';
import RepoCard from './RepoCard';
import { mapToRepo } from 'utilities/objectModels';
@ -61,34 +61,34 @@ const useStyles = makeStyles(() => ({
}
}));
function Home({ data, updateData }) {
function Home() {
const [isLoading, setIsLoading] = useState(true);
const [homeData, setHomeData] = useState([]);
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
useEffect(() => {
window.scrollTo(0, 0);
setIsLoading(true);
api
.get(`${host()}${endpoints.repoList}`)
.get(`${host()}${endpoints.repoList}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.RepoListWithNewestImage;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
updateData(repoData);
setHomeData(repoData);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
});
}, [updateData]);
useEffect(() => {
setHomeData(data);
}, [data]);
return () => {
abortController.abort();
};
}, []);
const renderPreviewCards = () => {
return (

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
// utility
import { api, endpoints } from '../api';
@ -71,11 +71,12 @@ function IsDependentOn(props) {
const { name } = props;
const classes = useStyles();
const [isLoading, setIsLoading] = useState(true);
const abortController = useMemo(() => new AbortController(), []);
useEffect(() => {
setIsLoading(true);
api
.get(`${host()}${endpoints.isDependentOnForImage(name)}`)
.get(`${host()}${endpoints.isDependentOnForImage(name)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let images = response.data.data.DerivedImageList;
@ -87,6 +88,9 @@ function IsDependentOn(props) {
console.error(e);
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, []);
const renderDependents = () => {

View File

@ -1,6 +1,6 @@
// react global
import { useParams } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
// utility
import { api, endpoints } from '../api';
@ -138,11 +138,12 @@ function RepoDetails() {
// get url param from <Route here (i.e. image name)
const { name } = useParams();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
useEffect(() => {
api
.get(`${host()}${endpoints.detailedRepoInfo(name)}`)
.get(`${host()}${endpoints.detailedRepoInfo(name)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let repoInfo = response.data.data.ExpandedRepoInfo;
@ -169,6 +170,9 @@ function RepoDetails() {
setRepoDetailData({});
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, [name]);
//function that returns a random element from an array
// function getRandom(list) {

View File

@ -75,6 +75,7 @@ function SearchSuggestion() {
const [searchQuery, setSearchQuery] = useState('');
const [suggestionData, setSuggestionData] = useState([]);
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
const handleSuggestionSelected = (event) => {
@ -94,7 +95,10 @@ function SearchSuggestion() {
setSearchQuery(value);
if (value !== '' && value.length > 1) {
api
.get(`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`)
.get(
`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`,
abortController.signal
)
.then((suggestionResponse) => {
if (suggestionResponse.data.data.GlobalSearch.Repos) {
const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Repos.map((el) => mapToRepo(el));
@ -114,6 +118,7 @@ function SearchSuggestion() {
useEffect(() => {
return () => {
debounceSuggestions.cancel();
abortController.abort();
};
});

View File

@ -1,5 +1,5 @@
// react global
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { host } from '../host';
// utility
@ -107,6 +107,7 @@ export default function SignIn({ isAuthEnabled, setIsAuthEnabled, isLoggedIn, se
const [requestProcessing, setRequestProcessing] = useState(false);
const [requestError, setRequestError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const abortController = useMemo(() => new AbortController(), []);
const navigate = useNavigate();
const classes = useStyles();
@ -117,7 +118,7 @@ export default function SignIn({ isAuthEnabled, setIsAuthEnabled, isLoggedIn, se
navigate('/home');
} else {
api
.get(`${host()}/v2/`)
.get(`${host()}/v2/`, abortController.signal)
.then((response) => {
if (response.status === 200) {
setIsAuthEnabled(false);
@ -131,6 +132,9 @@ export default function SignIn({ isAuthEnabled, setIsAuthEnabled, isLoggedIn, se
setIsLoading(false);
});
}
return () => {
abortController.abort();
};
}, []);
const handleClick = (event) => {
@ -146,7 +150,7 @@ export default function SignIn({ isAuthEnabled, setIsAuthEnabled, isLoggedIn, se
};
}
api
.get(`${host()}${endpoints.repoList}`, cfg)
.get(`${host()}${endpoints.repoList}`, abortController.signal, cfg)
.then((response) => {
if (response.data && response.data.data) {
if (isAuthEnabled) {

View File

@ -1,6 +1,6 @@
// react global
import { useParams } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
// utility
import { api, endpoints } from '../api';
@ -123,6 +123,7 @@ function TagDetails() {
//const [isLoading, setIsLoading] = useState(false);
const [selectedTab, setSelectedTab] = useState('Layers');
const [fullName, setFullName] = useState('');
const abortController = useMemo(() => new AbortController(), []);
// get url param from <Route here (i.e. image name)
const { name, tag } = useParams();
@ -134,7 +135,7 @@ function TagDetails() {
setSelectedTab('Layers');
window?.scrollTo(0, 0);
api
.get(`${host()}${endpoints.detailedImageInfo(name, tag)}`)
.get(`${host()}${endpoints.detailedImageInfo(name, tag)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let imageInfo = response.data.data.Image;
@ -158,6 +159,9 @@ function TagDetails() {
console.error(e);
setImageDetailData({});
});
return () => {
abortController.abort();
};
}, [name, tag]);
//function that returns a random element from an array
// function getRandom(list) {

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
// utility
import { api, endpoints } from '../api';
@ -260,12 +260,13 @@ function VulnerabilitiesDetails(props) {
const classes = useStyles();
const [cveData, setCveData] = useState({});
const [isLoading, setIsLoading] = useState(true);
const abortController = useMemo(() => new AbortController(), []);
const { name } = props;
useEffect(() => {
setIsLoading(true);
api
.get(`${host()}${endpoints.vulnerabilitiesForRepo(name)}`)
.get(`${host()}${endpoints.vulnerabilitiesForRepo(name)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let cveInfo = response.data.data.CVEListForImage;
@ -280,6 +281,9 @@ function VulnerabilitiesDetails(props) {
console.error(e);
setCveData({});
});
return () => {
abortController.abort();
};
}, []);
const renderCVEs = (cves) => {

View File

@ -25,7 +25,7 @@ const useStyles = makeStyles(() => ({
}
}));
function HomePage({ data, updateData }) {
function HomePage() {
const classes = useStyles();
return (
@ -34,7 +34,7 @@ function HomePage({ data, updateData }) {
<Container className={classes.container}>
<Grid container className={classes.gridWrapper}>
<Grid item className={classes.tile}>
<Home data={data} updateData={updateData} />
<Home />
</Grid>
</Grid>
</Container>