Repository: opossum-tool/OpossumUI
Pull request #2403 (2023-12-11 20:38:33)

feat: virtualize autocomplete
### Summary of changes

- rebuild autocomplete for virtualization with react-virtuoso using MUI useAutocomplete
- re-declare forwardRef such that it can be used together with generics

### Context and reason for change

#2385 

### How can the changes be tested

Use a large input file to notice that the autocompletes (such as "package name" and "licenses") open quickly compared to base branch.
Also test that the locator popup autocomplete still looks and works as expected.


Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index c2521021..d18ac5a0 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -127,6 +127,7 @@ module.exports = {
     'jest.config.js',
     'notices.template.html',
     'vite.config.mts',
+    'typings',
   ],
   reportUnusedDisableDirectives: true,
   rules: {
diff --git a/jest.config.js b/jest.config.js
index 96f68c99..c7df668c 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -7,7 +7,7 @@ module.exports = {
   testEnvironment: 'jsdom',
   resetMocks: false,
   transformIgnorePatterns: ['/node_modules/(?!d3-*|internmap|axios)'],
-  setupFilesAfterEnv: ['./setupTests.ts'],
+  setupFilesAfterEnv: ['./src/shared/setupTests.ts'],
   watchAll: false,
   roots: ['<rootDir>/src/Frontend', '<rootDir>/src/ElectronBackend'],
   modulePathIgnorePatterns: ['<rootDir>/build/'],
diff --git a/src/Frontend/Components/AttributionColumn/PackageAutocomplete.tsx b/src/Frontend/Components/AttributionColumn/PackageAutocomplete.tsx
index 3810cb7b..1556bd2f 100644
--- a/src/Frontend/Components/AttributionColumn/PackageAutocomplete.tsx
+++ b/src/Frontend/Components/AttributionColumn/PackageAutocomplete.tsx
@@ -56,6 +56,7 @@ const Badge = styled(MuiBadge)({
   '& .MuiBadge-badge': {
     top: '1px',
     right: '14px',
+    zIndex: 0,
   },
 });
 
diff --git a/src/Frontend/Components/Autocomplete/Autocomplete.style.ts b/src/Frontend/Components/Autocomplete/Autocomplete.style.ts
deleted file mode 100644
index 62beb3ec..00000000
--- a/src/Frontend/Components/Autocomplete/Autocomplete.style.ts
+++ /dev/null
@@ -1,56 +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 { styled, SxProps } from '@mui/material';
-import MuiTextField from '@mui/material/TextField';
-
-import { OpossumColors } from '../../shared-styles';
-
-export const Group = styled('div')({
-  display: 'flex',
-  alignItems: 'center',
-  gap: '8px',
-  top: '-8px',
-  position: 'sticky',
-  padding: '4px 10px',
-  zIndex: 10, // needs to be higher than that of elements rendered as start or end icon
-  backgroundColor: OpossumColors.lightBlue,
-});
-
-export const Input = styled(MuiTextField, {
-  shouldForwardProp: (name) => name !== 'highlight',
-})<{
-  highlight: 'default' | 'dark' | undefined;
-}>(({ highlight }) => ({
-  '& .MuiInputLabel-root': {
-    backgroundColor: highlight
-      ? highlight === 'default'
-        ? OpossumColors.lightOrange
-        : OpossumColors.darkOrange
-      : OpossumColors.white,
-    padding: '1px 3px',
-    fontSize: '13px',
-  },
-  '& .MuiInputBase-root': {
-    backgroundColor: highlight
-      ? highlight === 'default'
-        ? OpossumColors.lightOrange
-        : OpossumColors.darkOrange
-      : OpossumColors.white,
-    borderRadius: '0px',
-  },
-  '& legend': {
-    '& span': {
-      display: 'none',
-    },
-  },
-}));
-
-export const styles = {
-  overflowEllipsis: {
-    overflow: 'hidden',
-    textOverflow: 'ellipsis',
-    whiteSpace: 'nowrap',
-  },
-} satisfies SxProps;
diff --git a/src/Frontend/Components/Autocomplete/Autocomplete.style.tsx b/src/Frontend/Components/Autocomplete/Autocomplete.style.tsx
new file mode 100644
index 00000000..2c9a24fe
--- /dev/null
+++ b/src/Frontend/Components/Autocomplete/Autocomplete.style.tsx
@@ -0,0 +1,102 @@
+// 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 { Popper, PopperProps, styled, SxProps } from '@mui/material';
+import MuiTextField from '@mui/material/TextField';
+
+import { OpossumColors } from '../../shared-styles';
+
+export const Container = styled('div')({
+  flex: 1,
+});
+
+export const TagsContainer = styled('div')({
+  display: 'flex',
+  gap: '8px',
+  flexWrap: 'wrap',
+});
+
+export const Input = styled(MuiTextField, {
+  shouldForwardProp: (name: string) =>
+    !['highlight', 'numberOfEndAdornments'].includes(name),
+})<{
+  highlight: 'default' | 'dark' | undefined;
+  numberOfEndAdornments: number;
+}>(({ highlight, numberOfEndAdornments }) => ({
+  '& .MuiInputLabel-root': {
+    backgroundColor: highlight
+      ? highlight === 'default'
+        ? OpossumColors.lightOrange
+        : OpossumColors.darkOrange
+      : OpossumColors.white,
+    padding: '0px 3px',
+    fontSize: '13px',
+    top: '1px',
+  },
+  '& .MuiInputBase-root': {
+    backgroundColor: highlight
+      ? highlight === 'default'
+        ? OpossumColors.lightOrange
+        : OpossumColors.darkOrange
+      : OpossumColors.white,
+    borderRadius: '0px',
+    display: 'flex',
+    flexWrap: 'wrap',
+    alignItems: 'center',
+    gap: '4px',
+    minHeight: '36.67px',
+    paddingTop: '6px',
+    paddingBottom: '6px',
+    paddingLeft: '14px',
+    paddingRight: `calc(14px + ${numberOfEndAdornments} * 28px)`,
+  },
+  '& .MuiInputBase-input': {
+    flex: 1,
+    padding: 0,
+  },
+  '& legend': {
+    '& span': {
+      display: 'none',
+    },
+  },
+}));
+
+export const StyledPopper = styled((props: PopperProps) => (
+  <Popper
+    {...props}
+    placement={'auto'}
+    modifiers={[
+      {
+        name: 'preventOverflow',
+        enabled: true,
+      },
+      {
+        name: 'flip',
+        options: {
+          padding: 64,
+          allowedAutoPlacements: ['top', 'bottom'],
+        },
+      },
+    ]}
+  />
+))(({ theme, anchorEl }) => ({
+  width: (anchorEl as HTMLElement | null)?.clientWidth,
+  zIndex: theme.zIndex.modal,
+}));
+
+export const EndAdornmentContainer = styled('div')({
+  position: 'absolute',
+  right: '14px',
+  display: 'flex',
+  height: '100%',
+  alignItems: 'center',
+});
+
+export const styles = {
+  overflowEllipsis: {
+    overflow: 'hidden',
+    textOverflow: 'ellipsis',
+    whiteSpace: 'nowrap',
+  },
+} satisfies SxProps;
diff --git a/src/Frontend/Components/Autocomplete/Autocomplete.tsx b/src/Frontend/Components/Autocomplete/Autocomplete.tsx
index 558d82d9..636965bc 100644
--- a/src/Frontend/Components/Autocomplete/Autocomplete.tsx
+++ b/src/Frontend/Components/Autocomplete/Autocomplete.tsx
@@ -2,140 +2,236 @@
 // SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
 //
 // SPDX-License-Identifier: Apache-2.0
-import MuiAutocomplete, {
-  AutocompleteProps as MuiAutocompleteProps,
-} from '@mui/material/Autocomplete';
-import { ChipTypeMap as MuiChipTypeMap } from '@mui/material/Chip';
-import MuiListItemText from '@mui/material/ListItemText';
-import MuiTypography from '@mui/material/Typography';
-import { AutocompleteFreeSoloValueMapping as MuiAutocompleteFreeSoloValueMapping } from '@mui/material/useAutocomplete';
-import { useState } from 'react';
+import { SxProps } from '@mui/material';
+import MuiChip from '@mui/material/Chip';
+import MuiFade from '@mui/material/Fade';
+import { IconButtonProps as MuiIconButtonProps } from '@mui/material/IconButton';
+import { InputBaseComponentProps as MuiInputBaseComponentProps } from '@mui/material/InputBase';
+import useMuiAutocomplete, {
+  AutocompleteHighlightChangeReason,
+  UseAutocompleteProps as MuiUseAutocompleteProps,
+} from '@mui/material/useAutocomplete';
+import { compact } from 'lodash';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { VirtuosoHandle } from 'react-virtuoso';
 
-import { Group, Input, styles } from './Autocomplete.style';
+import { ClearButton } from '../ClearButton/ClearButton';
+import { PopupIndicator } from '../PopupIndicator/PopupIndicator';
+import {
+  Container,
+  EndAdornmentContainer,
+  Input,
+  StyledPopper,
+} from './Autocomplete.style';
+import { Listbox, ListboxProps } from './Listbox/Listbox';
 
 type AutocompleteProps<
   Value,
   Multiple extends boolean | undefined,
   DisableClearable extends boolean | undefined,
   FreeSolo extends boolean | undefined,
-  ChipComponent extends React.ElementType = MuiChipTypeMap['defaultComponent'],
 > = Omit<
-  MuiAutocompleteProps<
-    Value,
-    Multiple,
-    DisableClearable,
-    FreeSolo,
-    ChipComponent
-  >,
-  | 'ListboxComponent'
-  | 'onClose'
-  | 'onOpen'
-  | 'open'
-  | 'renderGroup'
-  | 'renderInput'
-  | 'renderOption'
-> & {
-  highlight?: 'default' | 'dark';
-  optionText: {
-    primary: (
-      option: Value | MuiAutocompleteFreeSoloValueMapping<FreeSolo>,
-    ) => React.ReactNode;
-    secondary?: (
-      option: Value | MuiAutocompleteFreeSoloValueMapping<FreeSolo>,
-    ) => React.ReactNode;
+  MuiUseAutocompleteProps<Value, Multiple, DisableClearable, FreeSolo>,
+  'onClose' | 'onOpen' | 'open' | 'onHighlightChange'
+> &
+  Pick<
+    ListboxProps<Value, FreeSolo>,
+    | 'getOptionKey'
+    | 'groupIcon'
+    | 'optionText'
+    | 'renderOptionEndIcon'
+    | 'renderOptionStartIcon'
+  > & {
+    endAdornment?: React.ReactNode;
+    highlight?: 'default' | 'dark';
+    inputProps?: MuiInputBaseComponentProps;
+    sx?: SxProps;
+    title: string;
   };
-  groupIcon?: React.ReactNode;
-  endAdornment?: React.ReactNode;
-  getOptionKey?: (
-    option: Value | MuiAutocompleteFreeSoloValueMapping<FreeSolo>,
-  ) => string;
-  renderOptionStartIcon?: (
-    option: Value,
-    { closePopper }: { closePopper: () => void },
-  ) => React.ReactNode;
-  renderOptionEndIcon?: (
-    option: Value,
-    { closePopper }: { closePopper: () => void },
-  ) => React.ReactNode;
-};
 
 export function Autocomplete<
   Value,
   Multiple extends boolean | undefined,
   DisableClearable extends boolean | undefined,
   FreeSolo extends boolean | undefined,
-  ChipComponent extends React.ElementType = MuiChipTypeMap['defaultComponent'],
 >({
+  disableClearable,
+  disableListWrap = true,
+  disabled,
   endAdornment,
+  freeSolo,
   getOptionKey,
+  getOptionLabel,
+  groupBy,
   groupIcon,
   highlight,
+  inputProps,
+  multiple,
   optionText,
   renderOptionEndIcon,
   renderOptionStartIcon,
   sx,
   title,
   ...props
-}: AutocompleteProps<
-  Value,
-  Multiple,
-  DisableClearable,
-  FreeSolo,
-  ChipComponent
->) {
+}: AutocompleteProps<Value, Multiple, DisableClearable, FreeSolo>) {
   const [open, setOpen] = useState(false);
   const closePopper = () => setOpen(false);
 
+  const groupedOptionsRef = useRef<Array<Value> | null>(null);
+  const virtuosoRef = useRef<VirtuosoHandle>(null);
+  const onHighlightChange = useCallback(
+    (
+      event: React.SyntheticEvent,
+      option: Value | null,
+      reason: AutocompleteHighlightChangeReason,
+    ) => {
+      if (reason !== 'keyboard' || event.type !== 'keydown' || !option) {
+        return;
+      }
+
+      const index = groupedOptionsRef.current?.findIndex(
+        (item) => item === option,
+      );
+
+      if (index !== undefined) {
+        virtuosoRef.current?.scrollToIndex({
+          index,
+          behavior: 'auto',
+          align: 'center',
+        });
+      }
+    },
+    [],
+  );
+
+  const {
+    anchorEl,
+    dirty: containsValue,
+    getClearProps,
+    getInputLabelProps,
+    getInputProps,
+    getListboxProps,
+    getOptionProps,
+    getPopupIndicatorProps,
+    getRootProps,
+    getTagProps,
+    groupedOptions,
+    popupOpen,
+    setAnchorEl,
+    value,
+  } = useMuiAutocomplete<Value, Multiple, DisableClearable, FreeSolo>({
+    disableClearable,
+    disableListWrap,
+    disabled,
+    freeSolo,
+    getOptionLabel,
+    multiple,
+    onClose: () => setOpen(false),
+    onHighlightChange,
+    onOpen: () => setOpen(true),
+    open,
+    ...props,
+  });
+
+  useEffect(() => {
+    groupedOptionsRef.current = groupedOptions as Array<Value>;
+  }, [groupedOptions]);
+
+  const hasPopupIndicator = !freeSolo;
+  const hasClearButton = !disableClearable && !disabled && containsValue;
+  const isPopupOpen = !!groupedOptions.length && popupOpen;
+
   return (
-    <MuiAutocomplete<Value, Multiple, DisableClearable, FreeSolo, ChipComponent>
-      open={open}
-      onOpen={() => setOpen(true)}
-      onClose={() => setOpen(false)}
-      sx={{ ...sx, flex: 1 }}
-      size={'small'}
-      renderGroup={(params) => (
-        <li key={params.key}>
-          <Group>
-            {groupIcon}
-            <MuiTypography sx={{ paddingTop: '2px' }}>
-              {params.group}
-            </MuiTypography>
-          </Group>
-          <ul style={{ padding: 0 }}>{params.children}</ul>
-        </li>
-      )}
-      renderOption={(
-        props: React.HTMLAttributes<HTMLLIElement> & React.Attributes,
-        option,
-      ) => (
-        <li {...props} key={getOptionKey?.(option) || props.key}>
-          {renderOptionStartIcon?.(option, { closePopper })}
-          <MuiListItemText
-            primary={optionText.primary(option)}
-            primaryTypographyProps={{ sx: styles.overflowEllipsis }}
-            secondary={optionText.secondary?.(option)}
-            secondaryTypographyProps={{
-              variant: 'caption',
-              sx: styles.overflowEllipsis,
-            }}
-          />
-          {renderOptionEndIcon?.(option, { closePopper })}
-        </li>
-      )}
-      disableListWrap
-      renderInput={({ InputProps, ...params }) => (
+    <>
+      <Container {...getRootProps()} sx={sx} ref={setAnchorEl}>
         <Input
-          {...params}
+          disabled={disabled}
           label={title}
           highlight={highlight}
+          numberOfEndAdornments={
+            compact([hasClearButton, hasPopupIndicator, !!endAdornment]).length
+          }
           size={'small'}
+          InputLabelProps={getInputLabelProps()}
+          inputProps={{ ...getInputProps(), ...inputProps }}
           InputProps={{
-            ...InputProps,
-            endAdornment: endAdornment || InputProps.endAdornment,
+            startAdornment: renderStartAdornment(),
+            endAdornment: renderEndAdornment(),
+          }}
+          onKeyDown={(event) => {
+            // https://github.com/mui/material-ui/issues/21129
+            event.key === 'Backspace' && event.stopPropagation();
           }}
+          fullWidth
         />
-      )}
-      {...props}
-    />
+      </Container>
+      {renderPopper()}
+    </>
   );
+
+  function renderStartAdornment() {
+    if (!multiple || !Array.isArray(value) || !value.length) {
+      return null;
+    }
+
+    return value.map((option, index) => {
+      const { key, ...tagProps } = getTagProps({ index });
+      const label = getOptionLabel?.(option) ?? option;
+
+      return (
+        <MuiChip
+          size={'small'}
+          key={key}
+          label={label}
+          {...tagProps}
+          data-testid={`tag-${label}`}
+        />
+      );
+    });
+  }
+
+  function renderEndAdornment() {
+    if (!endAdornment && !hasPopupIndicator && !hasClearButton) {
+      return null;
+    }
+
+    return (
+      <EndAdornmentContainer>
+        {hasClearButton && (
+          <ClearButton {...(getClearProps() as MuiIconButtonProps)} />
+        )}
+        {hasPopupIndicator && (
+          <PopupIndicator
+            popupOpen={isPopupOpen}
+            {...(getPopupIndicatorProps() as MuiIconButtonProps)}
+          />
+        )}
+        {endAdornment}
+      </EndAdornmentContainer>
+    );
+  }
+
+  function renderPopper() {
+    return (
+      <StyledPopper anchorEl={anchorEl} open={isPopupOpen} transition>
+        {({ TransitionProps }) => (
+          <MuiFade {...TransitionProps} timeout={300}>
+            <Listbox
+              {...getListboxProps()}
+              virtuosoRef={virtuosoRef}
+              closePopper={closePopper}
+              optionText={optionText}
+              options={groupedOptions as Array<Value>}
+              groupBy={groupBy}
+              groupIcon={groupIcon}
+              getOptionKey={getOptionKey}
+              getOptionProps={getOptionProps}
+              renderOptionStartIcon={renderOptionStartIcon}
+              renderOptionEndIcon={renderOptionEndIcon}
+            />
+          </MuiFade>
+        )}
+      </StyledPopper>
+    );
+  }
 }
diff --git a/src/Frontend/Components/Autocomplete/Listbox/Listbox.style.ts b/src/Frontend/Components/Autocomplete/Listbox/Listbox.style.ts
new file mode 100644
index 00000000..6970f722
--- /dev/null
+++ b/src/Frontend/Components/Autocomplete/Listbox/Listbox.style.ts
@@ -0,0 +1,27 @@
+// 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 { styled, SxProps } from '@mui/material';
+
+import { OpossumColors } from '../../../shared-styles';
+
+export const GroupContainer = styled('div')({
+  display: 'flex',
+  alignItems: 'center',
+  gap: '8px',
+  padding: '4px 10px',
+  backgroundColor: OpossumColors.lightBlue,
+});
+
+export const styles = {
+  overflowEllipsis: {
+    overflow: 'hidden',
+    textOverflow: 'ellipsis',
+    whiteSpace: 'nowrap',
+  },
+  virtuoso: {
+    maxHeight: '40vh',
+    transition: 'height all 0.2s ease-out',
+  },
+} satisfies SxProps;
diff --git a/src/Frontend/Components/Autocomplete/Listbox/Listbox.tsx b/src/Frontend/Components/Autocomplete/Listbox/Listbox.tsx
new file mode 100644
index 00000000..9e4641db
--- /dev/null
+++ b/src/Frontend/Components/Autocomplete/Listbox/Listbox.tsx
@@ -0,0 +1,191 @@
+// 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 MuiListItemButton from '@mui/material/ListItemButton';
+import MuiListItemText from '@mui/material/ListItemText';
+import MuiPaper from '@mui/material/Paper';
+import MuiTypography from '@mui/material/Typography';
+import {
+  AutocompleteFreeSoloValueMapping,
+  UseAutocompleteRenderedOption,
+} from '@mui/material/useAutocomplete';
+import { groupBy as _groupBy } from 'lodash';
+import { forwardRef, useMemo, useState } from 'react';
+import { GroupedVirtuoso, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
+
+import { GroupContainer, styles } from './Listbox.style';
+
+export type ListboxProps<
+  Value,
+  FreeSolo extends boolean | undefined,
+> = React.HTMLAttributes<HTMLElement> & {
+  virtuosoRef: React.RefObject<VirtuosoHandle>;
+  options: Array<Value>;
+  groupIcon?: React.ReactNode;
+  closePopper: () => void;
+  groupBy?: (option: Value) => string;
+  getOptionKey?: (
+    option: Value | AutocompleteFreeSoloValueMapping<FreeSolo>,
+  ) => string;
+  getOptionProps: (
+    renderedOption: UseAutocompleteRenderedOption<Value>,
+  ) => React.HTMLAttributes<HTMLElement> & React.Attributes;
+  renderOptionStartIcon?: (
+    option: Value,
+    { closePopper }: { closePopper: () => void },
+  ) => React.ReactNode;
+  renderOptionEndIcon?: (
+    option: Value,
+    { closePopper }: { closePopper: () => void },
+  ) => React.ReactNode;
+  optionText: {
+    primary: (
+      option: Value | AutocompleteFreeSoloValueMapping<FreeSolo>,
+    ) => React.ReactNode;
+    secondary?: (
+      option: Value | AutocompleteFreeSoloValueMapping<FreeSolo>,
+    ) => React.ReactNode;
+  };
+};
+
+interface Groups<Value> {
+  options: Array<Value>;
+  groupNames: Array<string>;
+  groupCounts: Array<number>;
+  firstSelectedIndex: number;
+}
+
+export const Listbox = forwardRef(
+  <Value, FreeSolo extends boolean | undefined>(
+    {
+      virtuosoRef,
+      closePopper,
+      getOptionKey,
+      getOptionProps,
+      groupBy,
+      groupIcon,
+      optionText,
+      options,
+      renderOptionEndIcon,
+      renderOptionStartIcon,
+      ...listboxProps
+    }: ListboxProps<Value, FreeSolo>,
+    forwardedRef: React.ForwardedRef<HTMLDivElement>,
+  ) => {
+    const [height, setHeight] = useState<number>(Number.MAX_SAFE_INTEGER); // will result in max-height
+
+    const groups = useMemo((): Groups<Value> | undefined => {
+      if (!groupBy) {
+        return undefined;
+      }
+
+      const grouped = _groupBy(options, groupBy);
+      const flattened = Object.values(grouped).flat();
+
+      return {
+        options: flattened,
+        groupNames: Object.keys(grouped),
+        groupCounts: Object.values(grouped).map((group) => group.length),
+        firstSelectedIndex: flattened.findIndex(
+          (option, index) =>
+            !!getOptionProps({ option, index })['aria-selected'],
+        ),
+      };
+    }, [getOptionProps, groupBy, options]);
+
+    return (
+      <MuiPaper
+        {...listboxProps}
+        ref={forwardedRef}
+        elevation={4}
+        square
+        role={'listbox'}
+      >
+        {groups ? renderGroupedList(groups) : renderList(options)}
+      </MuiPaper>
+    );
+
+    function renderGroupedList({
+      groupCounts,
+      groupNames,
+      options,
+      firstSelectedIndex,
+    }: Groups<Value>) {
+      return (
+        <GroupedVirtuoso
+          ref={virtuosoRef}
+          style={{ ...styles.virtuoso, height: height + groupNames.length - 1 }}
+          overscan={4}
+          increaseViewportBy={2}
+          initialTopMostItemIndex={
+            ~firstSelectedIndex && {
+              index: firstSelectedIndex,
+              align: 'center',
+            }
+          }
+          totalListHeightChanged={setHeight}
+          groupCounts={groupCounts}
+          groupContent={(index) => (
+            <GroupContainer role={'group'}>
+              {groupIcon}
+              <MuiTypography
+                sx={{ ...styles.overflowEllipsis, paddingTop: '2px' }}
+              >
+                {groupNames[index]}
+              </MuiTypography>
+            </GroupContainer>
+          )}
+          itemContent={(index) =>
+            renderOption({ index, option: options[index] })
+          }
+        />
+      );
+    }
+
+    function renderList(options: Array<Value>) {
+      return (
+        <Virtuoso
+          ref={virtuosoRef}
+          style={{ ...styles.virtuoso, height: height + 2 }}
+          data={options}
+          itemContent={(index, option) => renderOption({ option, index })}
+          overscan={4}
+          increaseViewportBy={2}
+          totalListHeightChanged={setHeight}
+        />
+      );
+    }
+
+    function renderOption({
+      index,
+      option,
+    }: UseAutocompleteRenderedOption<Value>) {
+      const { key, ...optionProps } = getOptionProps({
+        option,
+        index,
+      });
+
+      return (
+        <MuiListItemButton
+          {...optionProps}
+          selected={optionProps['aria-selected'] as boolean}
+          disabled={optionProps['aria-disabled'] as boolean}
+          key={getOptionKey?.(option) ?? key}
+        >
+          {renderOptionStartIcon?.(option, { closePopper })}
+          <MuiListItemText
+            primary={optionText.primary(option)}
+            primaryTypographyProps={{ sx: styles.overflowEllipsis }}
+            secondary={optionText.secondary?.(option)}
+            secondaryTypographyProps={{
+              variant: 'caption',
+              sx: styles.overflowEllipsis,
+            }}
+          />
+          {renderOptionEndIcon?.(option, { closePopper })}
+        </MuiListItemButton>
+      );
+    }
+  },
+);
diff --git a/src/Frontend/Components/ClearButton/ClearButton.tsx b/src/Frontend/Components/ClearButton/ClearButton.tsx
new file mode 100644
index 00000000..d7447f2d
--- /dev/null
+++ b/src/Frontend/Components/ClearButton/ClearButton.tsx
@@ -0,0 +1,19 @@
+// 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 ClearIcon from '@mui/icons-material/Clear';
+import { styled } from '@mui/material';
+import MuiIconButton, {
+  IconButtonProps as MuiIconButtonProps,
+} from '@mui/material/IconButton';
+
+export const ClearButton = styled(
+  ({ size = 'small', ...props }: MuiIconButtonProps) => (
+    <MuiIconButton aria-label={'clear button'} size={size} {...props}>
+      <ClearIcon fontSize={'small'} />
+    </MuiIconButton>
+  ),
+)({
+  padding: '4px',
+});
diff --git a/src/Frontend/Components/InputElements/Dropdown.style.ts b/src/Frontend/Components/InputElements/Dropdown.style.ts
new file mode 100644
index 00000000..cddcad55
--- /dev/null
+++ b/src/Frontend/Components/InputElements/Dropdown.style.ts
@@ -0,0 +1,12 @@
+// 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 { styled } from '@mui/system';
+
+import { PopupIndicator } from '../PopupIndicator/PopupIndicator';
+
+export const StyledPopupIndicator = styled(PopupIndicator)({
+  top: 'calc(50% - 14px) !important',
+  right: '14px !important',
+});
diff --git a/src/Frontend/Components/InputElements/Dropdown.tsx b/src/Frontend/Components/InputElements/Dropdown.tsx
index b8bf3f45..ad04ce4f 100644
--- a/src/Frontend/Components/InputElements/Dropdown.tsx
+++ b/src/Frontend/Components/InputElements/Dropdown.tsx
@@ -8,6 +8,7 @@ import MuiTextField from '@mui/material/TextField';
 import MuiTypography from '@mui/material/Typography';
 import { ReactElement } from 'react';
 
+import { StyledPopupIndicator } from './Dropdown.style';
 import { inputElementClasses, InputElementProps } from './shared';
 
 interface DropdownProps extends InputElementProps {
@@ -47,6 +48,7 @@ export function Dropdown(props: DropdownProps): ReactElement {
             : {}),
         }}
         select
+        SelectProps={{ IconComponent: StyledPopupIndicator }}
         label={props.title}
         InputProps={{
           inputProps: {
@@ -59,15 +61,13 @@ export function Dropdown(props: DropdownProps): ReactElement {
         variant="outlined"
         size="small"
       >
-        {menuItems.map((menuItem) => {
-          return (
-            <MuiMenuItem value={menuItem.value} key={menuItem.name}>
-              <MuiTypography sx={{ lineHeight: 1.3 }}>
-                {menuItem.name}
-              </MuiTypography>
-            </MuiMenuItem>
-          );
-        })}
+        {menuItems.map((menuItem) => (
+          <MuiMenuItem value={menuItem.value} key={menuItem.name}>
+            <MuiTypography sx={{ lineHeight: 1.3, paddingTop: '2px' }}>
+              {menuItem.name}
+            </MuiTypography>
+          </MuiMenuItem>
+        ))}
       </MuiTextField>
     </MuiBox>
   );
diff --git a/src/Frontend/Components/LocatorPopup/LocatorPopup.tsx b/src/Frontend/Components/LocatorPopup/LocatorPopup.tsx
index e0abf3e3..2a39a20a 100644
--- a/src/Frontend/Components/LocatorPopup/LocatorPopup.tsx
+++ b/src/Frontend/Components/LocatorPopup/LocatorPopup.tsx
@@ -166,11 +166,12 @@ export function LocatorPopup(): ReactElement {
         optionText={{ primary: (option) => option }}
         multiple
         title={text.locatorPopup.license}
-        aria-label={'auto complete'}
         value={[...searchedLicenses]}
         onChange={(_, value) => {
           setSearchedLicenses(new Set(value));
         }}
+        filterSelectedOptions
+        inputProps={{ 'aria-label': 'auto complete' }}
       />
       {showNoSignalsLocatedMessage ? (
         <MuiTypography variant={'subtitle2'} sx={classes.noSignalsMessage}>
diff --git a/src/Frontend/Components/PopupIndicator/PopupIndicator.tsx b/src/Frontend/Components/PopupIndicator/PopupIndicator.tsx
new file mode 100644
index 00000000..0804e062
--- /dev/null
+++ b/src/Frontend/Components/PopupIndicator/PopupIndicator.tsx
@@ -0,0 +1,22 @@
+// 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 ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+import { styled } from '@mui/material';
+import MuiIconButton, {
+  IconButtonProps as MuiIconButtonProps,
+} from '@mui/material/IconButton';
+
+export const PopupIndicator = styled(
+  ({ size = 'small', ...props }: MuiIconButtonProps) => (
+    <MuiIconButton aria-label={'popup indicator'} size={size} {...props}>
+      <ArrowDropDownIcon />
+    </MuiIconButton>
+  ),
+  { shouldForwardProp: (prop) => prop !== 'popupOpen' },
+)<{ popupOpen?: boolean }>(({ popupOpen }) => ({
+  padding: '2px',
+  transform: popupOpen ? 'rotate(180deg)' : 'rotate(0deg)',
+  transition: 'all 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
+}));
diff --git a/tsconfig.json b/tsconfig.json
index 36f356a3..4708d477 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,12 +28,6 @@
       "@testing-library/jest-dom"
     ]
   },
-  "include": [
-    "src/**/*",
-    ".eslintrc.js",
-    "setupTests.ts",
-    "vite.config.mts",
-    "index.html"
-  ],
+  "include": ["src/**/*", "typings"],
   "exclude": ["src/ElectronBackend/**/*"]
 }
diff --git a/typings/react.d.ts b/typings/react.d.ts
new file mode 100644
index 00000000..68f92f3b
--- /dev/null
+++ b/typings/react.d.ts
@@ -0,0 +1,13 @@
+// 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 { ReactElement, Ref, RefAttributes } from 'react';
+
+declare module 'react' {
+  // Overwrite type definition of forwardRef to allow forwarding generic components.
+  // See https://fettblog.eu/typescript-react-generic-forward-refs/
+  function forwardRef<T, P>(
+    render: (props: P, ref: Ref<T>) => ReactElement | null,
+  ): (props: P & RefAttributes<T>) => ReactElement | null;
+}


Test patch:
diff --git a/src/Frontend/Components/Autocomplete/__tests__/Autocomplete.test.tsx b/src/Frontend/Components/Autocomplete/__tests__/Autocomplete.test.tsx
index 2cde8ec5..917f8a31 100644
--- a/src/Frontend/Components/Autocomplete/__tests__/Autocomplete.test.tsx
+++ b/src/Frontend/Components/Autocomplete/__tests__/Autocomplete.test.tsx
@@ -2,10 +2,12 @@
 // SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
 //
 // SPDX-License-Identifier: Apache-2.0
-import { render, screen } from '@testing-library/react';
+import { screen, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import { isEqual } from 'lodash';
 
 import { faker } from '../../../../shared/Faker';
+import { renderComponentWithStore } from '../../../test-helpers/render-component-with-store';
 import { Autocomplete } from '../Autocomplete';
 
 describe('Autocomplete', () => {
@@ -14,7 +16,7 @@ describe('Autocomplete', () => {
     const options = faker.helpers.multiple(() =>
       faker.string.alpha({ length: 8 }),
     );
-    render(
+    renderComponentWithStore(
       <Autocomplete
         title={title}
         options={options}
@@ -30,12 +32,66 @@ describe('Autocomplete', () => {
     });
   });
 
+  it('renders popup indicator when not free solo', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        value={options[0]}
+        optionText={{ primary: (option) => option }}
+      />,
+    );
+
+    expect(screen.getByLabelText('popup indicator')).toBeInTheDocument();
+  });
+
+  it('does not render popup indicator when free solo', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        value={options[0]}
+        optionText={{ primary: (option) => option }}
+        freeSolo
+      />,
+    );
+
+    expect(screen.queryByLabelText('popup indicator')).not.toBeInTheDocument();
+  });
+
+  it('renders custom end adornment even when free solo and not clearable', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    const testId = faker.string.sample();
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+        endAdornment={<div data-testid={testId} />}
+        freeSolo
+      />,
+    );
+
+    expect(screen.getByTestId(testId)).toBeInTheDocument();
+  });
+
   it('renders options when clicked', async () => {
     const title = faker.string.alpha({ length: 8 });
     const options = faker.helpers.multiple(() =>
       faker.string.alpha({ length: 8 }),
     );
-    render(
+    renderComponentWithStore(
       <Autocomplete
         title={title}
         options={options}
@@ -57,7 +113,7 @@ describe('Autocomplete', () => {
     const options = faker.helpers.multiple(() =>
       faker.string.alpha({ length: 8 }),
     );
-    render(
+    renderComponentWithStore(
       <Autocomplete
         title={title}
         options={[...options, option]}
@@ -73,4 +129,300 @@ describe('Autocomplete', () => {
       expect(screen.queryByText(option)).not.toBeInTheDocument();
     });
   });
+
+  it('selects option with mouse', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+    await userEvent.click(screen.getByText(options[0]));
+
+    expect(screen.getByDisplayValue(options[0])).toBeInTheDocument();
+    options.forEach((option) => {
+      option !== options[0] &&
+        expect(screen.queryByDisplayValue(option)).not.toBeInTheDocument();
+    });
+  });
+
+  it('selects option with keyboard', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+    await userEvent.keyboard('{ArrowDown}');
+    await userEvent.keyboard('{Enter}');
+
+    expect(screen.getByDisplayValue(options[0])).toBeInTheDocument();
+    options.forEach((option) => {
+      option !== options[0] &&
+        expect(screen.queryByDisplayValue(option)).not.toBeInTheDocument();
+    });
+  });
+
+  it('renders clear button when a value is selected', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        value={options[0]}
+        optionText={{ primary: (option) => option }}
+      />,
+    );
+
+    expect(screen.getByLabelText('clear button')).toBeInTheDocument();
+  });
+
+  it('does not render clear button when no value is selected', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+      />,
+    );
+
+    expect(screen.queryByLabelText('clear button')).not.toBeInTheDocument();
+  });
+
+  it('does not render clear button when value is selected but clearing is disabled', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        value={options[0]}
+        optionText={{ primary: (option) => option }}
+        disableClearable
+      />,
+    );
+
+    expect(screen.queryByLabelText('clear button')).not.toBeInTheDocument();
+  });
+
+  it('does not render clear button when value is selected but autocomplete is disabled', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+        value={options[0]}
+        disabled
+      />,
+    );
+
+    expect(screen.queryByLabelText('clear button')).not.toBeInTheDocument();
+  });
+
+  it('renders multiple values', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        value={options}
+        optionText={{ primary: (option) => option }}
+        multiple
+      />,
+    );
+
+    options.forEach((option) => {
+      expect(screen.getByTestId(`tag-${option}`)).toBeInTheDocument();
+    });
+  });
+
+  it('adds one of multiple values', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+        multiple
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+    await userEvent.click(screen.getByText(options[0]));
+
+    expect(screen.getByTestId(`tag-${options[0]}`)).toBeInTheDocument();
+  });
+
+  it('clears one of multiple values', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+        multiple
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+    await userEvent.click(screen.getByText(options[0]));
+    await userEvent.click(
+      within(screen.getByTestId(`tag-${options[0]}`)).getByTestId('CancelIcon'),
+    );
+
+    expect(screen.queryByText(options[0])).not.toBeInTheDocument();
+  });
+
+  it('closes popup when clicking on custom start icon', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    const testId = faker.string.sample();
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+        renderOptionStartIcon={(option, { closePopper }) => (
+          <div onClick={closePopper} data-testid={`${option}-${testId}`} />
+        )}
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+
+    expect(screen.getByText(options[0])).toBeInTheDocument();
+
+    await userEvent.click(screen.getByTestId(`${options[0]}-${testId}`));
+
+    expect(screen.queryByText(options[0])).not.toBeInTheDocument();
+  });
+
+  it('closes popup when clicking on custom end icon', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() =>
+      faker.string.alpha({ length: 8 }),
+    );
+    const testId = faker.string.sample();
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        optionText={{ primary: (option) => option }}
+        renderOptionEndIcon={(option, { closePopper }) => (
+          <div onClick={closePopper} data-testid={`${option}-${testId}`} />
+        )}
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+
+    expect(screen.getByText(options[0])).toBeInTheDocument();
+
+    await userEvent.click(screen.getByTestId(`${options[0]}-${testId}`));
+
+    expect(screen.queryByText(options[0])).not.toBeInTheDocument();
+  });
+
+  it('renders in grouped mode', () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() => ({
+      group: faker.string.alpha({ length: 8 }),
+      value: faker.string.alpha({ length: 8 }),
+    }));
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        value={options[0]}
+        groupBy={(option) => option.group}
+        getOptionLabel={(option) =>
+          typeof option === 'string' ? option : option.value
+        }
+        optionText={{
+          primary: (option) =>
+            typeof option === 'string' ? option : option.value,
+        }}
+      />,
+    );
+
+    expect(screen.getByLabelText(title)).toBeInTheDocument();
+    expect(screen.getByDisplayValue(options[0].value)).toBeInTheDocument();
+    options.forEach((option) => {
+      expect(screen.queryByText(option.value)).not.toBeInTheDocument();
+    });
+  });
+
+  it('selects option in grouped mode', async () => {
+    const title = faker.string.alpha({ length: 8 });
+    const options = faker.helpers.multiple(() => ({
+      group: faker.string.alpha({ length: 8 }),
+      value: faker.string.alpha({ length: 8 }),
+    }));
+    renderComponentWithStore(
+      <Autocomplete
+        title={title}
+        options={options}
+        groupBy={(option) => option.group}
+        getOptionLabel={(option) =>
+          typeof option === 'string' ? option : option.value
+        }
+        optionText={{
+          primary: (option) =>
+            typeof option === 'string' ? option : option.value,
+        }}
+      />,
+    );
+
+    await userEvent.click(screen.getByRole('combobox'));
+
+    expect(screen.getByText(options[0].group)).toBeInTheDocument();
+
+    await userEvent.click(screen.getByText(options[0].value));
+
+    expect(screen.getByDisplayValue(options[0].value)).toBeInTheDocument();
+    options.forEach((option) => {
+      !isEqual(option, options[0]) &&
+        expect(
+          screen.queryByDisplayValue(option.value),
+        ).not.toBeInTheDocument();
+    });
+  });
 });
diff --git a/src/Frontend/Components/LocatorPopup/__tests__/LocatorPopup.test.tsx b/src/Frontend/Components/LocatorPopup/__tests__/LocatorPopup.test.tsx
index 1e4dcda1..7342f54e 100644
--- a/src/Frontend/Components/LocatorPopup/__tests__/LocatorPopup.test.tsx
+++ b/src/Frontend/Components/LocatorPopup/__tests__/LocatorPopup.test.tsx
@@ -46,9 +46,7 @@ describe('Locator popup ', () => {
     ).not.toBeChecked();
     expect(screen.getByLabelText('Criticality')).toBeInTheDocument();
     expect(screen.getByText('Any')).toBeInTheDocument();
-    expect(
-      screen.getByRole('combobox', { name: 'License' }),
-    ).toBeInTheDocument();
+    expect(screen.getByLabelText('auto complete')).toBeInTheDocument();
   });
 
   it('selects criticality values using the dropdown', () => {
diff --git a/src/e2e-tests/page-objects/AttributionDetails.ts b/src/e2e-tests/page-objects/AttributionDetails.ts
index ed94795c..4fc5924a 100644
--- a/src/e2e-tests/page-objects/AttributionDetails.ts
+++ b/src/e2e-tests/page-objects/AttributionDetails.ts
@@ -100,9 +100,12 @@ export class AttributionDetails {
       { exact: true },
     );
     this.copyright = this.node.getByLabel('Copyright', { exact: true });
-    this.licenseName = this.node.getByRole('combobox', {
-      name: 'License Name',
-    });
+    this.licenseName = this.node.getByLabel(
+      text.attributionColumn.licenseName,
+      {
+        exact: true,
+      },
+    );
     this.licenseText = this.node.getByLabel(
       'License Text (to appear in attribution document)',
       { exact: true },
diff --git a/setupTests.ts b/src/shared/setupTests.ts
similarity index 96%
rename from setupTests.ts
rename to src/shared/setupTests.ts
index 2a7dbcd3..78fa0b2c 100644
--- a/setupTests.ts
+++ b/src/shared/setupTests.ts
@@ -5,7 +5,7 @@
 // SPDX-License-Identifier: Apache-2.0
 import '@testing-library/jest-dom';
 
-import { ElectronAPI } from './src/shared/shared-types';
+import { ElectronAPI } from './shared-types';
 
 // We suppress the recharts warning that is due to our mocking in tests.
 const SUBSTRINGS_TO_SUPPRESS_IN_CONSOLE_WARN = [
@@ -17,7 +17,7 @@ const SUBSTRINGS_TO_SUPPRESS_IN_CONSOLE_ERROR = [
   'should be wrapped into act(...)',
 ];
 
-jest.mock('./src/ElectronBackend/main/logger.ts');
+jest.mock('../ElectronBackend/main/logger.ts');
 
 const originalResizeObserver = window.ResizeObserver;
 const originalConsoleError = console.error;