Repository: opossum-tool/OpossumUI
Pull request #2458 (2024-01-04 10:50:07)

chore: remove axios dependency
### Summary of changes

- replace last remaining axios usage with fetch API
- simplify logic and tests for UpdateAppPopup
- extract common spinner

### Context and reason for change

There is no need for a library to do our fetching since we can use the `fetch` API. Some parts of the app already use the `fetch` API and this PR makes sure that all remaining requests also do so now.

### How can the changes be tested

Check that the "update app popup" still works as expected, i.e., shows message to download new release.


Patch:
diff --git a/package.json b/package.json
index d2ab269c..56ef9157 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,6 @@
     "@mui/system": "^5.15.3",
     "@reduxjs/toolkit": "^2.0.1",
     "@tanstack/react-query": "^5.17.1",
-    "axios": "^1.6.4",
     "buffer": "^6.0.3",
     "compare-versions": "^6.1.0",
     "dayjs": "^1.11.10",
@@ -73,7 +72,6 @@
     "@typescript-eslint/eslint-plugin": "^6.17.0",
     "@typescript-eslint/parser": "^6.17.0",
     "@vitejs/plugin-react": "^4.2.1",
-    "axios-mock-adapter": "^1.22.0",
     "babel-jest": "^29.7.0",
     "babel-plugin-transform-import-meta": "^2.2.1",
     "cross-env": "^7.0.3",
diff --git a/renovate.json5 b/renovate.json5
index d92c10c4..30316b80 100644
--- a/renovate.json5
+++ b/renovate.json5
@@ -22,8 +22,6 @@
         '@faker-js/faker',
         '@playwright/test',
         '@trivago/prettier-plugin-sort-imports',
-        'axios',
-        'axios-mock-adapter',
         'babel-jest',
         'babel-plugin-transform-import-meta',
         'buffer',
diff --git a/src/Frontend/Components/NotificationPopup/NotificationPopup.tsx b/src/Frontend/Components/NotificationPopup/NotificationPopup.tsx
index 357f18be..2db24c83 100644
--- a/src/Frontend/Components/NotificationPopup/NotificationPopup.tsx
+++ b/src/Frontend/Components/NotificationPopup/NotificationPopup.tsx
@@ -38,6 +38,7 @@ interface NotificationPopupProps {
   'aria-label'?: string;
   customAction?: ReactElement;
   background?: keyof typeof OpossumColors;
+  width?: number;
 }
 
 export function NotificationPopup(props: NotificationPopupProps): ReactElement {
@@ -54,6 +55,7 @@ export function NotificationPopup(props: NotificationPopupProps): ReactElement {
       }
       PaperProps={{
         sx: {
+          ...(props.width && { width: props.width }),
           ...(props.fullHeight && classes.fullHeightPaper),
           ...(props.background && {
             background: OpossumColors[props.background],
diff --git a/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx b/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx
index 6ba06ed9..762a3666 100644
--- a/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx
+++ b/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx
@@ -5,16 +5,13 @@
 import CheckIcon from '@mui/icons-material/Check';
 import ErrorIcon from '@mui/icons-material/Error';
 import WarningIcon from '@mui/icons-material/Warning';
-import {
-  CircularProgress,
-  Dialog,
-  DialogContent,
-  DialogTitle,
-  SvgIconProps,
-  Typography,
-} from '@mui/material';
+import MuiDialog from '@mui/material/Dialog';
+import MuiDialogContent from '@mui/material/DialogContent';
+import MuiDialogTitle from '@mui/material/DialogTitle';
+import { SvgIconProps } from '@mui/material/SvgIcon';
+import MuiTypography from '@mui/material/Typography';
 import dayjs from 'dayjs';
-import React, { useState } from 'react';
+import { useState } from 'react';
 
 import { AllowedFrontendChannels } from '../../../shared/ipc-channels';
 import { Log } from '../../../shared/shared-types';
@@ -25,6 +22,7 @@ import {
   LoggingListener,
   useIpcRenderer,
 } from '../../util/use-ipc-renderer';
+import { Spinner } from '../Spinner/Spinner';
 import {
   BreakableTypography,
   MessageContainer,
@@ -40,7 +38,7 @@ const icon: Record<
   info: { Component: CheckIcon, color: 'green' },
 };
 
-export function ProcessPopup(): React.ReactNode {
+export function ProcessPopup() {
   const [logs, setLogs] = useState<Array<Log>>([]);
   const [loading, setLoading] = useState(false);
 
@@ -64,40 +62,35 @@ export function ProcessPopup(): React.ReactNode {
   );
 
   return (
-    <Dialog open={loading} fullWidth>
-      <DialogTitle>{text.processPopup.title}</DialogTitle>
+    <MuiDialog open={loading} fullWidth>
+      <MuiDialogTitle>{text.processPopup.title}</MuiDialogTitle>
       {renderDialogContent()}
-    </Dialog>
+    </MuiDialog>
   );
 
-  function renderDialogContent(): React.ReactNode {
+  function renderDialogContent() {
     return logs.length ? (
-      <DialogContent>
+      <MuiDialogContent>
         {logs.map(({ date, level, message }, index) => (
           <MessageContainer key={index} data-testid={`message-${index + 1}`}>
             <MetaContainer>
               {renderIcon(index, level)}
-              <Typography color={'darkblue'}>
+              <MuiTypography color={'darkblue'}>
                 {dayjs(date).format('HH:mm:ss.SSS')}
-              </Typography>
-              <Typography>{'•'}</Typography>
+              </MuiTypography>
+              <MuiTypography>{'•'}</MuiTypography>
             </MetaContainer>
             <BreakableTypography>{message}</BreakableTypography>
           </MessageContainer>
         ))}
-      </DialogContent>
+      </MuiDialogContent>
     ) : null;
   }
 
-  function renderIcon(index: number, level: Log['level']): React.ReactNode {
+  function renderIcon(index: number, level: Log['level']) {
     const { color, Component } = icon[level];
     return index === logs.length - 1 ? (
-      <CircularProgress
-        disableShrink
-        size={15}
-        sx={baseIcon}
-        data-testid={'spinner'}
-      />
+      <Spinner size={15} />
     ) : (
       <Component sx={{ ...baseIcon, color }} />
     );
diff --git a/src/Frontend/Components/Spinner/Spinner.tsx b/src/Frontend/Components/Spinner/Spinner.tsx
index 6a08fe15..66c58599 100644
--- a/src/Frontend/Components/Spinner/Spinner.tsx
+++ b/src/Frontend/Components/Spinner/Spinner.tsx
@@ -1,34 +1,33 @@
-// SPDX-FileCopyrightText: Nico Carl <nicocarl@protonmail.com>
+// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
+// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
 //
 // SPDX-License-Identifier: Apache-2.0
-import { SxProps } from '@mui/material';
-import MuiBox from '@mui/material/Box';
-import MuiSpinner from '@mui/material/CircularProgress';
-import { ReactElement } from 'react';
+import { styled } from '@mui/material';
+import MuiCircularProgress from '@mui/material/CircularProgress';
 
-import { getSxFromPropsAndClasses } from '../../util/get-sx-from-props-and-classes';
+import { baseIcon } from '../../shared-styles';
 
-const classes = {
-  root: {
-    display: 'flex',
-    justifyContent: 'center',
-    alignItems: 'center',
-  },
-};
+const DEFAULT_SIZE = 16;
+
+const Container = styled('div')({
+  display: 'flex',
+  justifyContent: 'center',
+  alignItems: 'center',
+});
 
 interface SpinnerProps {
-  sx?: SxProps;
+  size?: number;
 }
 
-export function Spinner(props: SpinnerProps): ReactElement {
+export function Spinner({ size = DEFAULT_SIZE }: SpinnerProps) {
   return (
-    <MuiBox
-      sx={getSxFromPropsAndClasses({
-        styleClass: classes.root,
-        sxProps: props.sx,
-      })}
-    >
-      <MuiSpinner />
-    </MuiBox>
+    <Container>
+      <MuiCircularProgress
+        disableShrink
+        size={size}
+        sx={baseIcon}
+        data-testid={'spinner'}
+      />
+    </Container>
   );
 }
diff --git a/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.tsx b/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.tsx
index a464c282..5a4da548 100644
--- a/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.tsx
+++ b/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.tsx
@@ -4,10 +4,9 @@
 // SPDX-License-Identifier: Apache-2.0
 import MuiLink from '@mui/material/Link';
 import MuiTypography from '@mui/material/Typography';
-import { useQuery } from '@tanstack/react-query';
-import { ReactElement } from 'react';
 
 import commitInfo from '../../../commitInfo.json';
+import { text } from '../../../shared/text';
 import { ButtonText } from '../../enums/enums';
 import { closePopup } from '../../state/actions/view-actions/view-actions';
 import { useAppDispatch } from '../../state/hooks';
@@ -15,62 +14,58 @@ import { openUrl } from '../../util/open-url';
 import { Alert } from '../Alert/Alert';
 import { NotificationPopup } from '../NotificationPopup/NotificationPopup';
 import { Spinner } from '../Spinner/Spinner';
-import { searchLatestReleaseNameAndUrl } from './update-app-popup-helpers';
+import { useLatestRelease } from './UpdateAppPopup.util';
 
-export function UpdateAppPopup(): ReactElement {
+export function UpdateAppPopup() {
   const dispatch = useAppDispatch();
+  const { latestRelease, latestReleaseError, latestReleaseLoading } =
+    useLatestRelease();
 
-  function close(): void {
+  const handleClose = () => {
     dispatch(closePopup());
-  }
-
-  const { isLoading, data, isError, error } = useQuery({
-    queryKey: ['latestReleaseNameSearch'],
-    queryFn: () => searchLatestReleaseNameAndUrl(),
-    refetchOnWindowFocus: false,
-  });
-
-  const content = !isError ? (
-    isLoading ? (
-      <Spinner />
-    ) : data ? (
-      data.name === commitInfo.commitInfo ? (
-        'You have the latest version of the app.'
-      ) : (
-        <>
-          <MuiTypography>
-            There is a new release! You can download it using the following
-            link:
-            <br />
-            <MuiLink component="button" onClick={(): void => openUrl(data.url)}>
-              {data.name}
-            </MuiLink>
-          </MuiTypography>
-        </>
-      )
-    ) : (
-      'No information found.'
-    )
-  ) : (
-    <Alert
-      errorMessage={`Failed while fetching release data${
-        error instanceof Error ? `: ${error.message}` : ''
-      }`}
-    />
-  );
+  };
 
   return (
     <NotificationPopup
-      content={content}
-      header={'Check for updates'}
-      isOpen={true}
-      fullWidth={false}
+      content={renderContent()}
+      header={text.updateAppPopup.title}
+      isOpen
+      width={600}
       rightButtonConfig={{
-        onClick: close,
+        onClick: handleClose,
         buttonText: ButtonText.Close,
       }}
-      onBackdropClick={close}
-      onEscapeKeyDown={close}
+      onBackdropClick={handleClose}
+      onEscapeKeyDown={handleClose}
     />
   );
+
+  function renderContent() {
+    if (latestReleaseLoading) {
+      return <Spinner />;
+    }
+
+    if (latestReleaseError) {
+      return (
+        <Alert
+          errorMessage={text.updateAppPopup.fetchFailed(
+            latestReleaseError.message,
+          )}
+        />
+      );
+    }
+
+    if (!latestRelease || latestRelease.name === commitInfo.commitInfo) {
+      return text.updateAppPopup.noUpdateAvailable;
+    }
+
+    return (
+      <MuiTypography>
+        {text.updateAppPopup.updateAvailable}{' '}
+        <MuiLink href={'#'} onClick={() => openUrl(latestRelease.url)}>
+          {latestRelease.name}
+        </MuiLink>
+      </MuiTypography>
+    );
+  }
 }
diff --git a/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.util.ts b/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.util.ts
new file mode 100644
index 00000000..23f7a3a8
--- /dev/null
+++ b/src/Frontend/Components/UpdateAppPopup/UpdateAppPopup.util.ts
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
+// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
+//
+// SPDX-License-Identifier: Apache-2.0
+import { useQuery } from '@tanstack/react-query';
+
+interface LatestReleaseResponse {
+  tag_name: string;
+  html_url: string;
+}
+
+export function useLatestRelease() {
+  const { isLoading, data, error } = useQuery({
+    queryKey: ['latestRelease'],
+    queryFn: async () => {
+      const response = await fetch(
+        'https://api.github.com/repos/opossum-tool/OpossumUI/releases/latest',
+      );
+      const { html_url, tag_name }: LatestReleaseResponse =
+        await response.json();
+
+      return { html_url, tag_name };
+    },
+  });
+
+  return {
+    latestReleaseLoading: isLoading,
+    latestReleaseError: error,
+    latestRelease:
+      data?.tag_name && data.html_url
+        ? {
+            name: data.tag_name,
+            url: data.html_url,
+          }
+        : undefined,
+  };
+}
diff --git a/src/Frontend/Components/UpdateAppPopup/update-app-popup-helpers.ts b/src/Frontend/Components/UpdateAppPopup/update-app-popup-helpers.ts
deleted file mode 100644
index 20669d27..00000000
--- a/src/Frontend/Components/UpdateAppPopup/update-app-popup-helpers.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
-// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
-//
-// SPDX-License-Identifier: Apache-2.0
-import axios from 'axios';
-
-export async function searchLatestReleaseNameAndUrl(): Promise<{
-  name: string;
-  url: string;
-} | null> {
-  const response = await axios.get(
-    'https://api.github.com/repos/opossum-tool/OpossumUI/releases/latest',
-  );
-  if (!response.data) {
-    return null;
-  }
-  const name = response.data.name as string;
-  const url = response.data.html_url as string;
-  return { name, url };
-}
diff --git a/src/Frontend/util/package-search-api.ts b/src/Frontend/util/package-search-api.ts
index 3b11cc33..df563dcd 100644
--- a/src/Frontend/util/package-search-api.ts
+++ b/src/Frontend/util/package-search-api.ts
@@ -105,10 +105,6 @@ export interface GitHubLicenseResponse {
   content: string;
 }
 
-export interface LatestReleaseResponse {
-  tag_name?: string;
-}
-
 export interface TagResponse {
   name: string;
 }
diff --git a/src/shared/Faker.ts b/src/shared/Faker.ts
index 46b99ae3..14b161df 100644
--- a/src/shared/Faker.ts
+++ b/src/shared/Faker.ts
@@ -19,7 +19,6 @@ import {
   GitHubLicenseResponse,
   GitLabLicenseResponse,
   GitLabProjectResponse,
-  LatestReleaseResponse,
   Links,
   PackageSuggestion,
   PackageSystem,
@@ -505,15 +504,6 @@ class PackageSearchModule {
       ...props,
     };
   }
-
-  public static latestReleaseResponse(
-    props: Partial<LatestReleaseResponse> = {},
-  ): LatestReleaseResponse {
-    return {
-      tag_name: faker.system.semver(),
-      ...props,
-    };
-  }
 }
 
 class Faker extends NativeFaker {
diff --git a/src/shared/text.ts b/src/shared/text.ts
index 75a50df4..50041cb9 100644
--- a/src/shared/text.ts
+++ b/src/shared/text.ts
@@ -67,6 +67,13 @@ export const text = {
     onlySearchLicenseNames: 'Only search license names',
     license: 'License',
   },
+  updateAppPopup: {
+    title: 'Check for updates',
+    noUpdateAvailable: 'You have the latest version of OpossumUI installed.',
+    updateAvailable: 'There is a newer version of OpossumUI available:',
+    fetchFailed: (message: string) =>
+      `Failed to fetch latest release information: ${message}`,
+  },
   attributionFilters: {
     firstParty: 'First Party',
     thirdParty: 'Third Party',
diff --git a/yarn.lock b/yarn.lock
index b5da3e59..97006827 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4488,19 +4488,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"axios-mock-adapter@npm:^1.22.0":
-  version: 1.22.0
-  resolution: "axios-mock-adapter@npm:1.22.0"
-  dependencies:
-    fast-deep-equal: "npm:^3.1.3"
-    is-buffer: "npm:^2.0.5"
-  peerDependencies:
-    axios: ">= 0.17.0"
-  checksum: ebed27ef2b1d011c2b574116f799f7499b8686c81fd75286784d03cdf0886e71f955255abb5b6c8edf696f4d31bfdcc62003f8dbf454907db309624394deee15
-  languageName: node
-  linkType: hard
-
-"axios@npm:^1.6.1, axios@npm:^1.6.4":
+"axios@npm:^1.6.1":
   version: 1.6.4
   resolution: "axios@npm:1.6.4"
   dependencies:
@@ -7676,13 +7664,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-buffer@npm:^2.0.5":
-  version: 2.0.5
-  resolution: "is-buffer@npm:2.0.5"
-  checksum: 3261a8b858edcc6c9566ba1694bf829e126faa88911d1c0a747ea658c5d81b14b6955e3a702d59dabadd58fdd440c01f321aa71d6547105fd21d03f94d0597e7
-  languageName: node
-  linkType: hard
-
 "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7":
   version: 1.2.7
   resolution: "is-callable@npm:1.2.7"
@@ -9884,8 +9865,6 @@ __metadata:
     "@typescript-eslint/eslint-plugin": "npm:^6.17.0"
     "@typescript-eslint/parser": "npm:^6.17.0"
     "@vitejs/plugin-react": "npm:^4.2.1"
-    axios: "npm:^1.6.4"
-    axios-mock-adapter: "npm:^1.22.0"
     babel-jest: "npm:^29.7.0"
     babel-plugin-transform-import-meta: "npm:^2.2.1"
     buffer: "npm:^6.0.3"


Test patch:
diff --git a/src/Frontend/Components/UpdateAppPopup/__tests__/UpdateAppPopup.test.tsx b/src/Frontend/Components/UpdateAppPopup/__tests__/UpdateAppPopup.test.tsx
index aa117533..051228c1 100644
--- a/src/Frontend/Components/UpdateAppPopup/__tests__/UpdateAppPopup.test.tsx
+++ b/src/Frontend/Components/UpdateAppPopup/__tests__/UpdateAppPopup.test.tsx
@@ -2,103 +2,70 @@
 // SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
 //
 // SPDX-License-Identifier: Apache-2.0
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { screen } from '@testing-library/react';
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
 
 import commitInfo from '../../../../commitInfo.json';
+import { faker } from '../../../../shared/Faker';
+import { text } from '../../../../shared/text';
 import { renderComponent } from '../../../test-helpers/render';
 import { UpdateAppPopup } from '../UpdateAppPopup';
+import * as util from '../UpdateAppPopup.util';
 
 describe('UpdateAppPopup', () => {
-  const okStatus = 200;
-  const notFoundStatus = 404;
-  const axiosMock = new MockAdapter(axios);
-  const queryClient = new QueryClient({
-    defaultOptions: {
-      queries: {
-        retry: false,
+  it('shows message that a new release is available', () => {
+    jest.spyOn(util, 'useLatestRelease').mockReturnValue({
+      latestRelease: {
+        name: faker.system.semver(),
+        url: faker.internet.url(),
       },
-    },
-  });
+      latestReleaseError: null,
+      latestReleaseLoading: false,
+    });
+    renderComponent(<UpdateAppPopup />);
 
-  it('shows the popup with a link to a new release', async () => {
-    axiosMock
-      .onGet(
-        'https://api.github.com/repos/opossum-tool/OpossumUI/releases/latest',
-      )
-      .replyOnce(okStatus, {
-        name: 'Latest release',
-        html_url: 'some url',
-      });
-    renderComponent(
-      <QueryClientProvider client={queryClient}>
-        <UpdateAppPopup />
-      </QueryClientProvider>,
-    );
-    expect(screen.getByText('Check for updates')).toBeInTheDocument();
     expect(
-      await screen.findByText(
-        'There is a new release! You can download it using the following link:',
-      ),
+      screen.getByText(text.updateAppPopup.updateAvailable),
     ).toBeInTheDocument();
-    expect(await screen.findByText('Latest release')).toBeInTheDocument();
   });
 
-  it('shows the popup with no newer release', async () => {
-    axiosMock
-      .onGet(
-        'https://api.github.com/repos/opossum-tool/OpossumUI/releases/latest',
-      )
-      .replyOnce(okStatus, {
+  it('shows message that no newer release is available', () => {
+    jest.spyOn(util, 'useLatestRelease').mockReturnValue({
+      latestRelease: {
         name: commitInfo.commitInfo,
-        html_url: 'some url',
-      });
-    renderComponent(
-      <QueryClientProvider client={queryClient}>
-        <UpdateAppPopup />
-      </QueryClientProvider>,
-    );
-    expect(screen.getByText('Check for updates')).toBeInTheDocument();
+        url: faker.internet.url(),
+      },
+      latestReleaseError: null,
+      latestReleaseLoading: false,
+    });
+    renderComponent(<UpdateAppPopup />);
+
     expect(
-      await screen.findByText('You have the latest version of the app.'),
+      screen.getByText(text.updateAppPopup.noUpdateAvailable),
     ).toBeInTheDocument();
   });
 
-  it('shows the popup with no info found', async () => {
-    axiosMock
-      .onGet(
-        'https://api.github.com/repos/opossum-tool/OpossumUI/releases/latest',
-      )
-      .replyOnce(okStatus, null);
-    renderComponent(
-      <QueryClientProvider client={queryClient}>
-        <UpdateAppPopup />
-      </QueryClientProvider>,
-    );
-    expect(screen.getByText('Check for updates')).toBeInTheDocument();
+  it('shows error message', () => {
+    const error = faker.lorem.sentence();
+    jest.spyOn(util, 'useLatestRelease').mockReturnValue({
+      latestRelease: undefined,
+      latestReleaseError: Error(error),
+      latestReleaseLoading: false,
+    });
+    renderComponent(<UpdateAppPopup />);
+
     expect(
-      await screen.findByText('No information found.'),
+      screen.getByText(text.updateAppPopup.fetchFailed(error)),
     ).toBeInTheDocument();
   });
 
-  it('shows the popup with error', async () => {
-    axiosMock
-      .onGet(
-        'https://api.github.com/repos/opossum-tool/OpossumUI/releases/latest',
-      )
-      .replyOnce(notFoundStatus);
-    renderComponent(
-      <QueryClientProvider client={queryClient}>
-        <UpdateAppPopup />
-      </QueryClientProvider>,
-    );
-    expect(screen.getByText('Check for updates')).toBeInTheDocument();
-    expect(
-      await screen.findByText(
-        'Failed while fetching release data: Request failed with status code 404',
-      ),
-    ).toBeInTheDocument();
+  it('shows loading state', () => {
+    jest.spyOn(util, 'useLatestRelease').mockReturnValue({
+      latestRelease: undefined,
+      latestReleaseError: null,
+      latestReleaseLoading: true,
+    });
+    renderComponent(<UpdateAppPopup />);
+
+    expect(screen.getByTestId('spinner')).toBeInTheDocument();
   });
 });