From 2a35fe04a5c3a02cc98e330a648187916659640d Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Wed, 12 Feb 2025 14:56:28 +0100 Subject: [PATCH 01/26] feat: show NotSavedPopup before file import if necessary * just importing without saving leads to loss of most recent edits * this change required reintegrating ImportDialog with the GlobalPopup system * caused by NotSavedPopups heavy usage of redux state --- src/Frontend/Components/App/App.tsx | 2 -- .../BackendCommunication.tsx | 14 +++++++- .../Components/GlobalPopup/GlobalPopup.tsx | 5 +++ .../Components/ImportDialog/ImportDialog.tsx | 16 ++++----- .../ImportDialog/ImportDialogProvider.tsx | 34 ------------------- .../NotSavedPopup/NotSavedPopup.tsx | 5 ++- src/Frontend/enums/enums.ts | 1 + .../__tests__/popup-actions.test.ts | 6 ++-- .../actions/popup-actions/popup-actions.ts | 25 ++++++++++++-- .../state/actions/view-actions/types.ts | 10 +++++- .../actions/view-actions/view-actions.ts | 8 +++++ src/Frontend/state/reducers/view-reducer.ts | 9 +++++ src/Frontend/state/selectors/view-selector.ts | 5 +++ 13 files changed, 84 insertions(+), 56 deletions(-) delete mode 100644 src/Frontend/Components/ImportDialog/ImportDialogProvider.tsx diff --git a/src/Frontend/Components/App/App.tsx b/src/Frontend/Components/App/App.tsx index 4f97f4f35..9293bf2c6 100644 --- a/src/Frontend/Components/App/App.tsx +++ b/src/Frontend/Components/App/App.tsx @@ -15,7 +15,6 @@ import { useSignalsWorker } from '../../web-workers/use-signals-worker'; import { AuditView } from '../AuditView/AuditView'; import { ErrorFallback } from '../ErrorFallback/ErrorFallback'; import { GlobalPopup } from '../GlobalPopup/GlobalPopup'; -import { ImportDialogProvider } from '../ImportDialog/ImportDialogProvider'; import { ProcessPopup } from '../ProcessPopup/ProcessPopup'; import { ReportView } from '../ReportView/ReportView'; import { TopBar } from '../TopBar/TopBar'; @@ -40,7 +39,6 @@ export function App() { - {renderView()} diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index d2493173c..1e202698d 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -18,6 +18,7 @@ import { } from '../../../shared/shared-types'; import { PopupType } from '../../enums/enums'; import { ROOT_PATH } from '../../shared-constants'; +import { showImportDialog } from '../../state/actions/popup-actions/popup-actions'; import { resetResourceState, setBaseUrlsForSources, @@ -38,7 +39,11 @@ import { getAttributionsWithResources, removeSlashesFromFilesWithChildren, } from '../../util/get-attributions-with-resources'; -import { LoggingListener, useIpcRenderer } from '../../util/use-ipc-renderer'; +import { + LoggingListener, + ShowImportDialogListener, + useIpcRenderer, +} from '../../util/use-ipc-renderer'; export const BackendCommunication: React.FC = () => { const resources = useAppSelector(getResources); @@ -264,6 +269,13 @@ export const BackendCommunication: React.FC = () => { showUpdateAppPopupListener, [dispatch], ); + useIpcRenderer( + AllowedFrontendChannels.ImportFileShowDialog, + (_, fileFormat) => { + dispatch(showImportDialog(fileFormat)); + }, + [dispatch], + ); return null; }; diff --git a/src/Frontend/Components/GlobalPopup/GlobalPopup.tsx b/src/Frontend/Components/GlobalPopup/GlobalPopup.tsx index 83ef4bd1a..0d27b4cbd 100644 --- a/src/Frontend/Components/GlobalPopup/GlobalPopup.tsx +++ b/src/Frontend/Components/GlobalPopup/GlobalPopup.tsx @@ -8,6 +8,7 @@ import { useAppSelector } from '../../state/hooks'; import { getOpenPopup } from '../../state/selectors/view-selector'; import { PopupInfo } from '../../types/types'; import { ErrorPopup } from '../ErrorPopup/ErrorPopup'; +import { ImportDialog } from '../ImportDialog/ImportDialog'; import { NotSavedPopup } from '../NotSavedPopup/NotSavedPopup'; import { ProjectMetadataPopup } from '../ProjectMetadataPopup/ProjectMetadataPopup'; import { ProjectStatisticsPopup } from '../ProjectStatisticsPopup/ProjectStatisticsPopup'; @@ -25,6 +26,10 @@ function getPopupComponent(popupInfo: PopupInfo | null) { return ; case PopupType.UpdateAppPopup: return ; + case PopupType.ImportDialog: + return popupInfo?.fileFormat ? ( + + ) : null; default: return null; } diff --git a/src/Frontend/Components/ImportDialog/ImportDialog.tsx b/src/Frontend/Components/ImportDialog/ImportDialog.tsx index 137b4a20d..2ef031f3f 100644 --- a/src/Frontend/Components/ImportDialog/ImportDialog.tsx +++ b/src/Frontend/Components/ImportDialog/ImportDialog.tsx @@ -10,6 +10,8 @@ import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; import { FileFormatInfo, Log } from '../../../shared/shared-types'; import { text } from '../../../shared/text'; import { getDotOpossumFilePath } from '../../../shared/write-file'; +import { closePopup } from '../../state/actions/view-actions/view-actions'; +import { useAppDispatch } from '../../state/hooks'; import { LoggingListener, useIpcRenderer } from '../../util/use-ipc-renderer'; import { FilePathInput } from '../FilePathInput/FilePathInput'; import { LogDisplay } from '../LogDisplay/LogDisplay'; @@ -17,13 +19,11 @@ import { NotificationPopup } from '../NotificationPopup/NotificationPopup'; export interface ImportDialogProps { fileFormat: FileFormatInfo; - closeDialog: () => void; } -export const ImportDialog: React.FC = ({ - fileFormat, - closeDialog, -}) => { +export const ImportDialog: React.FC = ({ fileFormat }) => { + const dispatch = useAppDispatch(); + const [inputFilePath, setInputFilePath] = useState(''); const [opossumFilePath, setOpossumFilePath] = useState(''); @@ -74,7 +74,7 @@ export const ImportDialog: React.FC = ({ } function onCancel(): void { - closeDialog(); + dispatch(closePopup()); } async function onConfirm(): Promise { @@ -87,10 +87,8 @@ export const ImportDialog: React.FC = ({ ); if (success) { - closeDialog(); + dispatch(closePopup()); } - - setIsLoading(false); } return ( diff --git a/src/Frontend/Components/ImportDialog/ImportDialogProvider.tsx b/src/Frontend/Components/ImportDialog/ImportDialogProvider.tsx deleted file mode 100644 index 03360bf30..000000000 --- a/src/Frontend/Components/ImportDialog/ImportDialogProvider.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates -// SPDX-FileCopyrightText: TNG Technology Consulting GmbH -// -// SPDX-License-Identifier: Apache-2.0 -import { useState } from 'react'; - -import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; -import { FileFormatInfo } from '../../../shared/shared-types'; -import { - ShowImportDialogListener, - useIpcRenderer, -} from '../../util/use-ipc-renderer'; -import { ImportDialog } from './ImportDialog'; - -export const ImportDialogProvider: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); - const [fileFormat, setFileFormat] = useState(); - - useIpcRenderer( - AllowedFrontendChannels.ImportFileShowDialog, - (_, fileFormat) => { - setFileFormat(fileFormat); - setIsOpen(true); - }, - [], - ); - - return isOpen && fileFormat ? ( - setIsOpen(false)} - /> - ) : undefined; -}; diff --git a/src/Frontend/Components/NotSavedPopup/NotSavedPopup.tsx b/src/Frontend/Components/NotSavedPopup/NotSavedPopup.tsx index 41b764e67..e086c20a6 100644 --- a/src/Frontend/Components/NotSavedPopup/NotSavedPopup.tsx +++ b/src/Frontend/Components/NotSavedPopup/NotSavedPopup.tsx @@ -5,7 +5,7 @@ import { text } from '../../../shared/text'; import { closePopupAndUnsetTargets, - navigateToTargetResourceOrAttributionOrOpenFileDialog, + proceedFromUnsavedPopup, } from '../../state/actions/popup-actions/popup-actions'; import { useAppDispatch } from '../../state/hooks'; import { NotificationPopup } from '../NotificationPopup/NotificationPopup'; @@ -18,8 +18,7 @@ export function NotSavedPopup() { content={text.unsavedChangesPopup.message} header={text.unsavedChangesPopup.title} leftButtonConfig={{ - onClick: () => - dispatch(navigateToTargetResourceOrAttributionOrOpenFileDialog()), + onClick: () => dispatch(proceedFromUnsavedPopup()), buttonText: text.unsavedChangesPopup.discard, color: 'secondary', }} diff --git a/src/Frontend/enums/enums.ts b/src/Frontend/enums/enums.ts index c5366a1fb..3fa77f4aa 100644 --- a/src/Frontend/enums/enums.ts +++ b/src/Frontend/enums/enums.ts @@ -15,6 +15,7 @@ export enum PopupType { ProjectMetadataPopup = 'ProjectMetadataPopup', ProjectStatisticsPopup = 'ProjectStatisticsPopup', UpdateAppPopup = 'UpdateAppPopup', + ImportDialog = 'ImportDialog', } export enum ButtonText { diff --git a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts index 27d59216b..c1ca1de13 100644 --- a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts +++ b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts @@ -49,7 +49,7 @@ import { changeSelectedAttributionOrOpenUnsavedPopup, closePopupAndUnsetTargets, navigateToSelectedPathOrOpenUnsavedPopup, - navigateToTargetResourceOrAttributionOrOpenFileDialog, + proceedFromUnsavedPopup, setSelectedResourceIdOrOpenUnsavedPopup, setViewOrOpenUnsavedPopup, } from '../popup-actions'; @@ -321,9 +321,7 @@ describe('The actions called from the unsaved popup', () => { testStore.dispatch(setTargetView(View.Audit)); testStore.dispatch(openPopup(PopupType.NotSavedPopup)); testStore.dispatch(setTargetSelectedResourceId('newSelectedResource')); - testStore.dispatch( - navigateToTargetResourceOrAttributionOrOpenFileDialog(), - ); + testStore.dispatch(proceedFromUnsavedPopup()); return testStore.getState(); } diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index d26dd7560..ff239b529 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { PackageInfo } from '../../../../shared/shared-types'; +import { FileFormatInfo, PackageInfo } from '../../../../shared/shared-types'; import { PopupType, View } from '../../../enums/enums'; import { EMPTY_DISPLAY_PACKAGE_INFO } from '../../../shared-constants'; import { @@ -12,6 +12,7 @@ import { getSelectedResourceId, } from '../../selectors/resource-selectors'; import { + getImportFileRequest, getOpenFileRequest, getTargetView, } from '../../selectors/view-selector'; @@ -31,6 +32,7 @@ import { closePopup, navigateToView, openPopup, + setImportFileRequest, setOpenFileRequest, setTargetView, } from '../view-actions/view-actions'; @@ -91,18 +93,25 @@ export function setSelectedResourceIdOrOpenUnsavedPopup( }; } -export function navigateToTargetResourceOrAttributionOrOpenFileDialog(): AppThunkAction { +export function proceedFromUnsavedPopup(): AppThunkAction { return (dispatch, getState) => { const targetView = getTargetView(getState()); const openFileRequest = getOpenFileRequest(getState()); + const importFileRequest = getImportFileRequest(getState()); dispatch(closePopup()); + if (openFileRequest) { void window.electronAPI.openFile(); dispatch(setOpenFileRequest(false)); return; } + if (importFileRequest) { + dispatch(openPopup(PopupType.ImportDialog, undefined, importFileRequest)); + return; + } + dispatch(setSelectedResourceOrAttributionIdToTargetValue()); if (targetView) { dispatch(navigateToView(targetView)); @@ -123,5 +132,17 @@ export function closePopupAndUnsetTargets(): AppThunkAction { dispatch(setTargetSelectedAttributionId('')); dispatch(closePopup()); dispatch(setOpenFileRequest(false)); + dispatch(setImportFileRequest(null)); + }; +} + +export function showImportDialog(fileFormat: FileFormatInfo): AppThunkAction { + return (dispatch, getState) => { + if (getIsPackageInfoModified(getState())) { + dispatch(setImportFileRequest(fileFormat)); + dispatch(openPopup(PopupType.NotSavedPopup)); + } else { + dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)); + } }; } diff --git a/src/Frontend/state/actions/view-actions/types.ts b/src/Frontend/state/actions/view-actions/types.ts index ba43ad6e9..ef226c896 100644 --- a/src/Frontend/state/actions/view-actions/types.ts +++ b/src/Frontend/state/actions/view-actions/types.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 +import { FileFormatInfo } from '../../../../shared/shared-types'; import { View } from '../../../enums/enums'; import { PopupInfo } from '../../../types/types'; @@ -11,6 +12,7 @@ export const ACTION_OPEN_POPUP = 'ACTION_OPEN_POPUP'; export const ACTION_CLOSE_POPUP = 'ACTION_CLOSE_POPUP'; export const ACTION_RESET_VIEW_STATE = 'ACTION_RESET_VIEW_STATE'; export const ACTION_SET_OPEN_FILE_REQUEST = 'ACTION_SET_OPEN_FILE_REQUEST'; +export const ACTION_SET_IMPORT_FILE_REQUEST = 'ACTION_SET_IMPORT_FILE_REQUEST'; export type ViewAction = | SetView @@ -18,7 +20,8 @@ export type ViewAction = | ClosePopupAction | ResetViewStateAction | OpenPopupAction - | SetOpenFileRequestAction; + | SetOpenFileRequestAction + | SetImportFileRequestAction; export interface ResetViewStateAction { type: typeof ACTION_RESET_VIEW_STATE; @@ -47,3 +50,8 @@ export interface SetOpenFileRequestAction { type: typeof ACTION_SET_OPEN_FILE_REQUEST; payload: boolean; } + +export interface SetImportFileRequestAction { + type: typeof ACTION_SET_IMPORT_FILE_REQUEST; + payload: FileFormatInfo | null; +} diff --git a/src/Frontend/state/actions/view-actions/view-actions.ts b/src/Frontend/state/actions/view-actions/view-actions.ts index 37308f429..882a64c8c 100644 --- a/src/Frontend/state/actions/view-actions/view-actions.ts +++ b/src/Frontend/state/actions/view-actions/view-actions.ts @@ -14,12 +14,14 @@ import { ACTION_CLOSE_POPUP, ACTION_OPEN_POPUP, ACTION_RESET_VIEW_STATE, + ACTION_SET_IMPORT_FILE_REQUEST, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, ACTION_SET_VIEW, ClosePopupAction, OpenPopupAction, ResetViewStateAction, + SetImportFileRequestAction, SetOpenFileRequestAction, SetTargetView, SetView, @@ -85,3 +87,9 @@ export function setOpenFileRequest( ): SetOpenFileRequestAction { return { type: ACTION_SET_OPEN_FILE_REQUEST, payload: openFileRequest }; } + +export function setImportFileRequest( + fileFormat: FileFormatInfo | null, +): SetImportFileRequestAction { + return { type: ACTION_SET_IMPORT_FILE_REQUEST, payload: fileFormat }; +} diff --git a/src/Frontend/state/reducers/view-reducer.ts b/src/Frontend/state/reducers/view-reducer.ts index 369c5fea2..4d9fc13f2 100644 --- a/src/Frontend/state/reducers/view-reducer.ts +++ b/src/Frontend/state/reducers/view-reducer.ts @@ -3,12 +3,14 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 +import { FileFormatInfo } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo } from '../../types/types'; import { ACTION_CLOSE_POPUP, ACTION_OPEN_POPUP, ACTION_RESET_VIEW_STATE, + ACTION_SET_IMPORT_FILE_REQUEST, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, ACTION_SET_VIEW, @@ -20,6 +22,7 @@ export interface ViewState { targetView: View | null; popupInfo: Array; openFileRequest: boolean; + importFileRequest: FileFormatInfo | null; } export const initialViewState: ViewState = { @@ -27,6 +30,7 @@ export const initialViewState: ViewState = { targetView: null, popupInfo: [], openFileRequest: false, + importFileRequest: null, }; export function viewState( @@ -65,6 +69,11 @@ export function viewState( ...state, openFileRequest: action.payload, }; + case ACTION_SET_IMPORT_FILE_REQUEST: + return { + ...state, + importFileRequest: action.payload, + }; default: return state; } diff --git a/src/Frontend/state/selectors/view-selector.ts b/src/Frontend/state/selectors/view-selector.ts index b2eaa4e79..678483ea3 100644 --- a/src/Frontend/state/selectors/view-selector.ts +++ b/src/Frontend/state/selectors/view-selector.ts @@ -3,6 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 +import { FileFormatInfo } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo, State } from '../../types/types'; @@ -37,3 +38,7 @@ export function getPopupAttributionId(state: State): string | null { export function getOpenFileRequest(state: State): boolean { return state.viewState.openFileRequest; } + +export function getImportFileRequest(state: State): FileFormatInfo | null { + return state.viewState.importFileRequest; +} From 5efb370a3a57f263248b2425815013436d45f445 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Wed, 12 Feb 2025 18:07:11 +0100 Subject: [PATCH 02/26] feat: also show NotSavedPopup when starting open file action from app menu --- src/ElectronBackend/main/menu.ts | 6 ++++-- .../BackendCommunication.tsx | 14 +++++++++---- src/Frontend/Components/TopBar/TopBar.tsx | 20 +++++-------------- .../actions/popup-actions/popup-actions.ts | 11 ++++++++++ src/shared/ipc-channels.ts | 1 + 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/ElectronBackend/main/menu.ts b/src/ElectronBackend/main/menu.ts index a78c04bc2..7a95700fe 100644 --- a/src/ElectronBackend/main/menu.ts +++ b/src/ElectronBackend/main/menu.ts @@ -19,7 +19,6 @@ import { } from './iconHelpers'; import { getImportFileListener, - getOpenFileListener, getSelectBaseURLListener, setLoadingState, } from './listeners'; @@ -113,7 +112,10 @@ export async function createMenu(mainWindow: BrowserWindow): Promise { ), label: 'Open File', accelerator: 'CmdOrCtrl+O', - click: getOpenFileListener(mainWindow, activateMenuItems), + click: () => + mainWindow.webContents.send( + AllowedFrontendChannels.OpenFileWithUnsavedCheck, + ), }, { icon: getIconBasedOnTheme( diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index 1e202698d..53ae1ee56 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -18,7 +18,10 @@ import { } from '../../../shared/shared-types'; import { PopupType } from '../../enums/enums'; import { ROOT_PATH } from '../../shared-constants'; -import { showImportDialog } from '../../state/actions/popup-actions/popup-actions'; +import { + openFileWithUnsavedCheck, + showImportDialog, +} from '../../state/actions/popup-actions/popup-actions'; import { resetResourceState, setBaseUrlsForSources, @@ -269,11 +272,14 @@ export const BackendCommunication: React.FC = () => { showUpdateAppPopupListener, [dispatch], ); + useIpcRenderer( + AllowedFrontendChannels.OpenFileWithUnsavedCheck, + () => dispatch(openFileWithUnsavedCheck()), + [dispatch], + ); useIpcRenderer( AllowedFrontendChannels.ImportFileShowDialog, - (_, fileFormat) => { - dispatch(showImportDialog(fileFormat)); - }, + (_, fileFormat) => dispatch(showImportDialog(fileFormat)), [dispatch], ); diff --git a/src/Frontend/Components/TopBar/TopBar.tsx b/src/Frontend/Components/TopBar/TopBar.tsx index 79c3c097a..ce137b03a 100644 --- a/src/Frontend/Components/TopBar/TopBar.tsx +++ b/src/Frontend/Components/TopBar/TopBar.tsx @@ -11,15 +11,13 @@ import MuiTypography from '@mui/material/Typography'; import { useState } from 'react'; import commitInfo from '../../../commitInfo.json'; -import { PopupType, View } from '../../enums/enums'; +import { View } from '../../enums/enums'; import { OpossumColors } from '../../shared-styles'; -import { setViewOrOpenUnsavedPopup } from '../../state/actions/popup-actions/popup-actions'; import { - openPopup, - setOpenFileRequest, -} from '../../state/actions/view-actions/view-actions'; + openFileWithUnsavedCheck, + setViewOrOpenUnsavedPopup, +} from '../../state/actions/popup-actions/popup-actions'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; -import { getIsPackageInfoModified } from '../../state/selectors/resource-selectors'; import { getSelectedView } from '../../state/selectors/view-selector'; import { useProgressData } from '../../state/variables/use-progress-data'; import { BackendCommunication } from '../BackendCommunication/BackendCommunication'; @@ -81,9 +79,6 @@ const classes = { export const TopBar: React.FC = () => { const selectedView = useAppSelector(getSelectedView); const dispatch = useAppDispatch(); - const isTemporaryPackageInfoModified = useAppSelector( - getIsPackageInfoModified, - ); const [showCriticalSignals, setShowCriticalSignals] = useState(false); const [progressData] = useProgressData(); @@ -96,12 +91,7 @@ export const TopBar: React.FC = () => { } function handleOpenFileClick(): void { - if (isTemporaryPackageInfoModified) { - dispatch(setOpenFileRequest(true)); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { - void window.electronAPI.openFile(); - } + dispatch(openFileWithUnsavedCheck()); } return ( diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index ff239b529..14057c6e4 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -146,3 +146,14 @@ export function showImportDialog(fileFormat: FileFormatInfo): AppThunkAction { } }; } + +export function openFileWithUnsavedCheck(): AppThunkAction { + return (dispatch, getState) => { + if (getIsPackageInfoModified(getState())) { + dispatch(setOpenFileRequest(true)); + dispatch(openPopup(PopupType.NotSavedPopup)); + } else { + void window.electronAPI.openFile(); + } + }; +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1c5529cc3..5fbc663c9 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -32,6 +32,7 @@ export enum AllowedFrontendChannels { SearchResources = 'search-resources', SearchSignals = 'search-signals', SetBaseURLForRoot = 'set-base-url-for-root', + OpenFileWithUnsavedCheck = 'open-file-with-unsaved-check', ImportFileShowDialog = 'import-file-show-dialog', ShowProjectMetadataPopup = 'show-project-metadata-pop-up', ShowProjectStatisticsPopup = 'show-project-statistics-pop-up', From 0711d9eb18435d69c2c775e8d731f38c3f6b984d Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 10:23:19 +0100 Subject: [PATCH 03/26] feat: show NotSavedPopup before export * exporting when there are unsaved changes can lead to unexpected behavior --- src/ElectronBackend/main/menu.ts | 19 +- .../BackendCommunication.tsx | 167 +----------------- .../actions/popup-actions/popup-actions.ts | 37 +++- .../resource-actions/export-actions.ts | 140 +++++++++++++++ .../resource-actions/preference-actions.ts | 2 +- .../state/actions/view-actions/types.ts | 11 +- .../actions/view-actions/view-actions.ts | 10 +- src/Frontend/state/reducers/view-reducer.ts | 10 +- src/Frontend/state/selectors/view-selector.ts | 6 +- .../get-attributions-with-resources.test.ts | 11 +- ...with-resources.ts => attribution-utils.ts} | 22 ++- 11 files changed, 241 insertions(+), 194 deletions(-) create mode 100644 src/Frontend/state/actions/resource-actions/export-actions.ts rename src/Frontend/util/{get-attributions-with-resources.ts => attribution-utils.ts} (90%) diff --git a/src/ElectronBackend/main/menu.ts b/src/ElectronBackend/main/menu.ts index 7a95700fe..e9fb1cad3 100644 --- a/src/ElectronBackend/main/menu.ts +++ b/src/ElectronBackend/main/menu.ts @@ -17,12 +17,7 @@ import { getIconBasedOnTheme, makeFirstIconVisibleAndSecondHidden, } from './iconHelpers'; -import { - getImportFileListener, - getSelectBaseURLListener, - setLoadingState, -} from './listeners'; -import logger from './logger'; +import { getImportFileListener, getSelectBaseURLListener } from './listeners'; import { getPathOfChromiumNoticeDocument, getPathOfNoticeDocument, @@ -157,8 +152,6 @@ export async function createMenu(mainWindow: BrowserWindow): Promise { 'icons/follow-up-black.png', ), click: () => { - setLoadingState(mainWindow.webContents, true); - logger.info('Preparing data for follow-up export'); webContents.send( AllowedFrontendChannels.ExportFileRequest, ExportType.FollowUp, @@ -174,8 +167,6 @@ export async function createMenu(mainWindow: BrowserWindow): Promise { ), label: INITIALLY_DISABLED_ITEMS_INFO.compactComponentList.label, click: () => { - setLoadingState(mainWindow.webContents, true); - logger.info('Preparing data for compact component list export'); webContents.send( AllowedFrontendChannels.ExportFileRequest, ExportType.CompactBom, @@ -191,10 +182,6 @@ export async function createMenu(mainWindow: BrowserWindow): Promise { ), label: INITIALLY_DISABLED_ITEMS_INFO.detailedComponentList.label, click: () => { - setLoadingState(mainWindow.webContents, true); - logger.info( - 'Preparing data for detailed component list export', - ); webContents.send( AllowedFrontendChannels.ExportFileRequest, ExportType.DetailedBom, @@ -210,8 +197,6 @@ export async function createMenu(mainWindow: BrowserWindow): Promise { ), label: INITIALLY_DISABLED_ITEMS_INFO.spdxYAML.label, click: () => { - setLoadingState(mainWindow.webContents, true); - logger.info('Preparing data for SPDX (yaml) export'); webContents.send( AllowedFrontendChannels.ExportFileRequest, ExportType.SpdxDocumentYaml, @@ -227,8 +212,6 @@ export async function createMenu(mainWindow: BrowserWindow): Promise { ), label: INITIALLY_DISABLED_ITEMS_INFO.spdxJSON.label, click: () => { - setLoadingState(mainWindow.webContents, true); - logger.info('Preparing data for SPDX (json) export'); webContents.send( AllowedFrontendChannels.ExportFileRequest, ExportType.SpdxDocumentJson, diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index 53ae1ee56..1cdccc67e 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -5,20 +5,16 @@ // SPDX-License-Identifier: Apache-2.0 import dayjs from 'dayjs'; import { IpcRendererEvent } from 'electron'; -import pick from 'lodash/pick'; import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; import { - Attributions, BaseURLForRootArgs, - ExportSpdxDocumentJsonArgs, - ExportSpdxDocumentYamlArgs, - ExportType, ParsedFileContent, } from '../../../shared/shared-types'; import { PopupType } from '../../enums/enums'; import { ROOT_PATH } from '../../shared-constants'; import { + exportFileWithUnsavedCheck, openFileWithUnsavedCheck, showImportDialog, } from '../../state/actions/popup-actions/popup-actions'; @@ -29,31 +25,15 @@ import { import { loadFromFile } from '../../state/actions/resource-actions/load-actions'; import { openPopup } from '../../state/actions/view-actions/view-actions'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; +import { getBaseUrlsForSources } from '../../state/selectors/resource-selectors'; import { - getAttributionBreakpoints, - getBaseUrlsForSources, - getFilesWithChildren, - getFrequentLicensesTexts, - getManualData, - getResources, -} from '../../state/selectors/resource-selectors'; -import { - getAttributionsWithAllChildResourcesWithoutFolders, - getAttributionsWithResources, - removeSlashesFromFilesWithChildren, -} from '../../util/get-attributions-with-resources'; -import { + ExportFileRequestListener, LoggingListener, ShowImportDialogListener, useIpcRenderer, } from '../../util/use-ipc-renderer'; export const BackendCommunication: React.FC = () => { - const resources = useAppSelector(getResources); - const manualData = useAppSelector(getManualData); - const attributionBreakpoints = useAppSelector(getAttributionBreakpoints); - const filesWithChildren = useAppSelector(getFilesWithChildren); - const frequentLicenseTexts = useAppSelector(getFrequentLicensesTexts); const baseUrlsForSources = useAppSelector(getBaseUrlsForSources); const dispatch = useAppDispatch(); @@ -66,118 +46,6 @@ export const BackendCommunication: React.FC = () => { dispatch(openPopup(PopupType.ProjectStatisticsPopup)); } - function getExportFileRequestListener( - _: IpcRendererEvent, - exportType: ExportType, - ): void { - switch (exportType) { - case ExportType.SpdxDocumentJson: - case ExportType.SpdxDocumentYaml: - return getSpdxDocumentExportListener(exportType); - case ExportType.FollowUp: - return getFollowUpExportListener(); - case ExportType.CompactBom: - return getCompactBomExportListener(); - case ExportType.DetailedBom: - return getDetailedBomExportListener(); - } - } - - function getFollowUpExportListener(): void { - const followUpAttributions = pick( - manualData.attributions, - Object.keys(manualData.attributions).filter( - (attributionId) => manualData.attributions[attributionId].followUp, - ), - ); - - const followUpAttributionsWithResources = - getAttributionsWithAllChildResourcesWithoutFolders( - followUpAttributions, - manualData.attributionsToResources, - manualData.resourcesToAttributions, - resources || {}, - attributionBreakpoints, - filesWithChildren, - ); - const followUpAttributionsWithFormattedResources = - removeSlashesFromFilesWithChildren( - followUpAttributionsWithResources, - filesWithChildren, - ); - - window.electronAPI.exportFile({ - type: ExportType.FollowUp, - followUpAttributionsWithResources: - followUpAttributionsWithFormattedResources, - }); - } - - function getSpdxDocumentExportListener( - exportType: ExportType.SpdxDocumentYaml | ExportType.SpdxDocumentJson, - ): void { - const attributions = Object.fromEntries( - Object.entries(manualData.attributions).map((entry) => { - const packageInfo = entry[1]; - - const licenseName = packageInfo.licenseName || ''; - const isFrequentLicense = - licenseName && licenseName in frequentLicenseTexts; - const licenseText = - packageInfo.licenseText || isFrequentLicense - ? frequentLicenseTexts[licenseName] - : ''; - return [ - entry[0], - { - ...entry[1], - licenseText, - }, - ]; - }), - ); - - const args: ExportSpdxDocumentYamlArgs | ExportSpdxDocumentJsonArgs = { - type: exportType, - spdxAttributions: attributions, - }; - - window.electronAPI.exportFile(args); - } - - function getDetailedBomExportListener(): void { - const bomAttributions = getBomAttributions( - manualData.attributions, - ExportType.DetailedBom, - ); - - const bomAttributionsWithResources = getAttributionsWithResources( - bomAttributions, - manualData.attributionsToResources, - ); - - const bomAttributionsWithFormattedResources = - removeSlashesFromFilesWithChildren( - bomAttributionsWithResources, - filesWithChildren, - ); - - window.electronAPI.exportFile({ - type: ExportType.DetailedBom, - bomAttributionsWithResources: bomAttributionsWithFormattedResources, - }); - } - - function getCompactBomExportListener(): void { - window.electronAPI.exportFile({ - type: ExportType.CompactBom, - bomAttributions: getBomAttributions( - manualData.attributions, - ExportType.CompactBom, - ), - }); - } - function resetLoadedFileListener( _: IpcRendererEvent, resetState: boolean, @@ -257,15 +125,10 @@ export const BackendCommunication: React.FC = () => { setBaseURLForRootListener, [dispatch, baseUrlsForSources], ); - useIpcRenderer( + useIpcRenderer( AllowedFrontendChannels.ExportFileRequest, - getExportFileRequestListener, - [ - manualData, - attributionBreakpoints, - frequentLicenseTexts, - filesWithChildren, - ], + (_, exportType) => dispatch(exportFileWithUnsavedCheck(exportType)), + [dispatch], ); useIpcRenderer( AllowedFrontendChannels.ShowUpdateAppPopup, @@ -285,21 +148,3 @@ export const BackendCommunication: React.FC = () => { return null; }; - -export function getBomAttributions( - attributions: Attributions, - exportType: ExportType, -): Attributions { - return pick( - attributions, - Object.keys(attributions).filter( - (attributionId) => - !attributions[attributionId].followUp && - !attributions[attributionId].firstParty && - !( - exportType === ExportType.CompactBom && - attributions[attributionId].excludeFromNotice - ), - ), - ); -} diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index 14057c6e4..67c84103f 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -3,7 +3,11 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { FileFormatInfo, PackageInfo } from '../../../../shared/shared-types'; +import { + ExportType, + FileFormatInfo, + PackageInfo, +} from '../../../../shared/shared-types'; import { PopupType, View } from '../../../enums/enums'; import { EMPTY_DISPLAY_PACKAGE_INFO } from '../../../shared-constants'; import { @@ -12,6 +16,7 @@ import { getSelectedResourceId, } from '../../selectors/resource-selectors'; import { + getExportFileRequest, getImportFileRequest, getOpenFileRequest, getTargetView, @@ -24,6 +29,7 @@ import { setTargetSelectedAttributionId, setTargetSelectedResourceId, } from '../resource-actions/audit-view-simple-actions'; +import { exportFile } from '../resource-actions/export-actions'; import { openResourceInResourceBrowser, setSelectedResourceOrAttributionIdToTargetValue, @@ -32,6 +38,7 @@ import { closePopup, navigateToView, openPopup, + setExportFileRequest, setImportFileRequest, setOpenFileRequest, setTargetView, @@ -95,9 +102,18 @@ export function setSelectedResourceIdOrOpenUnsavedPopup( export function proceedFromUnsavedPopup(): AppThunkAction { return (dispatch, getState) => { + // discard changes + dispatch( + setTemporaryDisplayPackageInfo( + getPackageInfoOfSelectedAttribution(getState()) || + EMPTY_DISPLAY_PACKAGE_INFO, + ), + ); + const targetView = getTargetView(getState()); const openFileRequest = getOpenFileRequest(getState()); const importFileRequest = getImportFileRequest(getState()); + const exportFileRequest = getExportFileRequest(getState()); dispatch(closePopup()); @@ -112,6 +128,11 @@ export function proceedFromUnsavedPopup(): AppThunkAction { return; } + if (exportFileRequest) { + dispatch(exportFile(exportFileRequest)); + return; + } + dispatch(setSelectedResourceOrAttributionIdToTargetValue()); if (targetView) { dispatch(navigateToView(targetView)); @@ -133,6 +154,7 @@ export function closePopupAndUnsetTargets(): AppThunkAction { dispatch(closePopup()); dispatch(setOpenFileRequest(false)); dispatch(setImportFileRequest(null)); + dispatch(setExportFileRequest(null)); }; } @@ -157,3 +179,16 @@ export function openFileWithUnsavedCheck(): AppThunkAction { } }; } + +export function exportFileWithUnsavedCheck( + exportType: ExportType, +): AppThunkAction { + return (dispatch, getState) => { + if (getIsPackageInfoModified(getState())) { + dispatch(setExportFileRequest(exportType)); + dispatch(openPopup(PopupType.NotSavedPopup)); + } else { + dispatch(exportFile(exportType)); + } + }; +} diff --git a/src/Frontend/state/actions/resource-actions/export-actions.ts b/src/Frontend/state/actions/resource-actions/export-actions.ts new file mode 100644 index 000000000..b958455cf --- /dev/null +++ b/src/Frontend/state/actions/resource-actions/export-actions.ts @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates +// SPDX-FileCopyrightText: TNG Technology Consulting GmbH +// SPDX-FileCopyrightText: Nico Carl +// +// SPDX-License-Identifier: Apache-2.0 +import pick from 'lodash/pick'; + +import { + ExportSpdxDocumentJsonArgs, + ExportSpdxDocumentYamlArgs, + ExportType, +} from '../../../../shared/shared-types'; +import { State } from '../../../types/types'; +import { + attributionUtils, + getAttributionsWithAllChildResourcesWithoutFolders, + getBomAttributions, + removeSlashesFromFilesWithChildren, +} from '../../../util/attribution-utils'; +import { + getAttributionBreakpoints, + getFilesWithChildren, + getFrequentLicensesTexts, + getManualData, + getResources, +} from '../../selectors/resource-selectors'; +import { AppThunkAction } from '../../types'; + +export function exportFile(exportType: ExportType): AppThunkAction { + return (_, getState) => { + switch (exportType) { + case ExportType.SpdxDocumentJson: + case ExportType.SpdxDocumentYaml: + return getSpdxDocumentExportListener(getState(), exportType); + case ExportType.FollowUp: + return getFollowUpExportListener(getState()); + case ExportType.CompactBom: + return getCompactBomExportListener(getState()); + case ExportType.DetailedBom: + return getDetailedBomExportListener(getState()); + } + }; +} + +function getFollowUpExportListener(state: State): void { + const followUpAttributions = pick( + getManualData(state).attributions, + Object.keys(getManualData(state).attributions).filter( + (attributionId) => + getManualData(state).attributions[attributionId].followUp, + ), + ); + + const followUpAttributionsWithResources = + getAttributionsWithAllChildResourcesWithoutFolders( + followUpAttributions, + getManualData(state).attributionsToResources, + getManualData(state).resourcesToAttributions, + getResources(state) || {}, + getAttributionBreakpoints(state), + getFilesWithChildren(state), + ); + const followUpAttributionsWithFormattedResources = + removeSlashesFromFilesWithChildren( + followUpAttributionsWithResources, + getFilesWithChildren(state), + ); + + window.electronAPI.exportFile({ + type: ExportType.FollowUp, + followUpAttributionsWithResources: + followUpAttributionsWithFormattedResources, + }); +} + +function getSpdxDocumentExportListener( + state: State, + exportType: ExportType.SpdxDocumentYaml | ExportType.SpdxDocumentJson, +): void { + const attributions = Object.fromEntries( + Object.entries(getManualData(state).attributions).map((entry) => { + const packageInfo = entry[1]; + + const licenseName = packageInfo.licenseName || ''; + const isFrequentLicense = + licenseName && licenseName in getFrequentLicensesTexts(state); + const licenseText = + packageInfo.licenseText || isFrequentLicense + ? getFrequentLicensesTexts(state)[licenseName] + : ''; + return [ + entry[0], + { + ...entry[1], + licenseText, + }, + ]; + }), + ); + + const args: ExportSpdxDocumentYamlArgs | ExportSpdxDocumentJsonArgs = { + type: exportType, + spdxAttributions: attributions, + }; + + window.electronAPI.exportFile(args); +} + +function getDetailedBomExportListener(state: State): void { + const bomAttributions = getBomAttributions( + getManualData(state).attributions, + ExportType.DetailedBom, + ); + + const bomAttributionsWithResources = attributionUtils( + bomAttributions, + getManualData(state).attributionsToResources, + ); + + const bomAttributionsWithFormattedResources = + removeSlashesFromFilesWithChildren( + bomAttributionsWithResources, + getFilesWithChildren(state), + ); + + window.electronAPI.exportFile({ + type: ExportType.DetailedBom, + bomAttributionsWithResources: bomAttributionsWithFormattedResources, + }); +} + +function getCompactBomExportListener(state: State): void { + window.electronAPI.exportFile({ + type: ExportType.CompactBom, + bomAttributions: getBomAttributions( + getManualData(state).attributions, + ExportType.CompactBom, + ), + }); +} diff --git a/src/Frontend/state/actions/resource-actions/preference-actions.ts b/src/Frontend/state/actions/resource-actions/preference-actions.ts index 5029061c5..775f1c862 100644 --- a/src/Frontend/state/actions/resource-actions/preference-actions.ts +++ b/src/Frontend/state/actions/resource-actions/preference-actions.ts @@ -14,7 +14,7 @@ import { ResourcesToAttributions, } from '../../../../shared/shared-types'; import { State } from '../../../types/types'; -import { getSubtree } from '../../../util/get-attributions-with-resources'; +import { getSubtree } from '../../../util/attribution-utils'; import { CalculatePreferredOverOriginIds } from '../../helpers/save-action-helpers'; import { ResourceState } from '../../reducers/resource-reducer'; import { diff --git a/src/Frontend/state/actions/view-actions/types.ts b/src/Frontend/state/actions/view-actions/types.ts index ef226c896..24cb6a57e 100644 --- a/src/Frontend/state/actions/view-actions/types.ts +++ b/src/Frontend/state/actions/view-actions/types.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { FileFormatInfo } from '../../../../shared/shared-types'; +import { ExportType, FileFormatInfo } from '../../../../shared/shared-types'; import { View } from '../../../enums/enums'; import { PopupInfo } from '../../../types/types'; @@ -13,6 +13,7 @@ export const ACTION_CLOSE_POPUP = 'ACTION_CLOSE_POPUP'; export const ACTION_RESET_VIEW_STATE = 'ACTION_RESET_VIEW_STATE'; export const ACTION_SET_OPEN_FILE_REQUEST = 'ACTION_SET_OPEN_FILE_REQUEST'; export const ACTION_SET_IMPORT_FILE_REQUEST = 'ACTION_SET_IMPORT_FILE_REQUEST'; +export const ACTION_SET_EXPORT_FILE_REQUEST = 'ACTION_SET_EXPORT_FILE_REQUEST'; export type ViewAction = | SetView @@ -21,7 +22,8 @@ export type ViewAction = | ResetViewStateAction | OpenPopupAction | SetOpenFileRequestAction - | SetImportFileRequestAction; + | SetImportFileRequestAction + | SetExportFileRequestAction; export interface ResetViewStateAction { type: typeof ACTION_RESET_VIEW_STATE; @@ -55,3 +57,8 @@ export interface SetImportFileRequestAction { type: typeof ACTION_SET_IMPORT_FILE_REQUEST; payload: FileFormatInfo | null; } + +export interface SetExportFileRequestAction { + type: typeof ACTION_SET_EXPORT_FILE_REQUEST; + payload: ExportType | null; +} diff --git a/src/Frontend/state/actions/view-actions/view-actions.ts b/src/Frontend/state/actions/view-actions/view-actions.ts index 882a64c8c..927536532 100644 --- a/src/Frontend/state/actions/view-actions/view-actions.ts +++ b/src/Frontend/state/actions/view-actions/view-actions.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { FileFormatInfo } from '../../../../shared/shared-types'; +import { ExportType, FileFormatInfo } from '../../../../shared/shared-types'; import { PopupType, View } from '../../../enums/enums'; import { EMPTY_DISPLAY_PACKAGE_INFO } from '../../../shared-constants'; import { State } from '../../../types/types'; @@ -14,6 +14,7 @@ import { ACTION_CLOSE_POPUP, ACTION_OPEN_POPUP, ACTION_RESET_VIEW_STATE, + ACTION_SET_EXPORT_FILE_REQUEST, ACTION_SET_IMPORT_FILE_REQUEST, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, @@ -21,6 +22,7 @@ import { ClosePopupAction, OpenPopupAction, ResetViewStateAction, + SetExportFileRequestAction, SetImportFileRequestAction, SetOpenFileRequestAction, SetTargetView, @@ -93,3 +95,9 @@ export function setImportFileRequest( ): SetImportFileRequestAction { return { type: ACTION_SET_IMPORT_FILE_REQUEST, payload: fileFormat }; } + +export function setExportFileRequest( + exportFileRequest: ExportType | null, +): SetExportFileRequestAction { + return { type: ACTION_SET_EXPORT_FILE_REQUEST, payload: exportFileRequest }; +} diff --git a/src/Frontend/state/reducers/view-reducer.ts b/src/Frontend/state/reducers/view-reducer.ts index 4d9fc13f2..0ec1bff0c 100644 --- a/src/Frontend/state/reducers/view-reducer.ts +++ b/src/Frontend/state/reducers/view-reducer.ts @@ -3,13 +3,14 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { FileFormatInfo } from '../../../shared/shared-types'; +import { ExportType, FileFormatInfo } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo } from '../../types/types'; import { ACTION_CLOSE_POPUP, ACTION_OPEN_POPUP, ACTION_RESET_VIEW_STATE, + ACTION_SET_EXPORT_FILE_REQUEST, ACTION_SET_IMPORT_FILE_REQUEST, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, @@ -23,6 +24,7 @@ export interface ViewState { popupInfo: Array; openFileRequest: boolean; importFileRequest: FileFormatInfo | null; + exportFileRequest: ExportType | null; } export const initialViewState: ViewState = { @@ -31,6 +33,7 @@ export const initialViewState: ViewState = { popupInfo: [], openFileRequest: false, importFileRequest: null, + exportFileRequest: null, }; export function viewState( @@ -74,6 +77,11 @@ export function viewState( ...state, importFileRequest: action.payload, }; + case ACTION_SET_EXPORT_FILE_REQUEST: + return { + ...state, + exportFileRequest: action.payload, + }; default: return state; } diff --git a/src/Frontend/state/selectors/view-selector.ts b/src/Frontend/state/selectors/view-selector.ts index 678483ea3..4222d52f6 100644 --- a/src/Frontend/state/selectors/view-selector.ts +++ b/src/Frontend/state/selectors/view-selector.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { FileFormatInfo } from '../../../shared/shared-types'; +import { ExportType, FileFormatInfo } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo, State } from '../../types/types'; @@ -42,3 +42,7 @@ export function getOpenFileRequest(state: State): boolean { export function getImportFileRequest(state: State): FileFormatInfo | null { return state.viewState.importFileRequest; } + +export function getExportFileRequest(state: State): ExportType | null { + return state.viewState.exportFileRequest; +} diff --git a/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts b/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts index f6bc87b0a..4bace74ab 100644 --- a/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts +++ b/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts @@ -8,10 +8,10 @@ import { Resources, } from '../../../shared/shared-types'; import { + attributionUtils, getAttributionsWithAllChildResourcesWithoutFolders, - getAttributionsWithResources, removeSlashesFromFilesWithChildren, -} from '../get-attributions-with-resources'; +} from '../attribution-utils'; describe('getAttributionsWithResources', () => { it('returns attributions with resources', () => { @@ -46,15 +46,12 @@ describe('getAttributionsWithResources', () => { }; expect( - getAttributionsWithResources( - testAttributions, - testAttributionsToResources, - ), + attributionUtils(testAttributions, testAttributionsToResources), ).toEqual(expectedAttributionsWithResources); }); it('returns attributions with resources for empty attributions', () => { - expect(getAttributionsWithResources({}, {})).toEqual({}); + expect(attributionUtils({}, {})).toEqual({}); }); }); diff --git a/src/Frontend/util/get-attributions-with-resources.ts b/src/Frontend/util/attribution-utils.ts similarity index 90% rename from src/Frontend/util/get-attributions-with-resources.ts rename to src/Frontend/util/attribution-utils.ts index de29d0a6e..1b863f2eb 100644 --- a/src/Frontend/util/get-attributions-with-resources.ts +++ b/src/Frontend/util/attribution-utils.ts @@ -3,10 +3,12 @@ // // SPDX-License-Identifier: Apache-2.0 import get from 'lodash/get'; +import pick from 'lodash/pick'; import { Attributions, AttributionsToResources, + ExportType, Resources, ResourcesToAttributions, } from '../../shared/shared-types'; @@ -16,7 +18,7 @@ import { isIdOfResourceWithChildren, } from './can-resource-have-children'; -export function getAttributionsWithResources( +export function attributionUtils( attributions: Attributions, attributionsToResources: AttributionsToResources, ): Attributions { @@ -174,3 +176,21 @@ export function removeSlashesFromFilesWithChildren( }), ); } + +export function getBomAttributions( + attributions: Attributions, + exportType: ExportType, +): Attributions { + return pick( + attributions, + Object.keys(attributions).filter( + (attributionId) => + !attributions[attributionId].followUp && + !attributions[attributionId].firstParty && + !( + exportType === ExportType.CompactBom && + attributions[attributionId].excludeFromNotice + ), + ), + ); +} From 2e2647d5e6811afb6558139fb8614150dfc272e2 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 10:34:48 +0100 Subject: [PATCH 04/26] refactor: move test to match previous refactoring --- .../__tests__/BackendCommunication.test.tsx | 72 ------------------- ...rces.test.ts => attribution-utils.test.ts} | 67 +++++++++++++++++ 2 files changed, 67 insertions(+), 72 deletions(-) delete mode 100644 src/Frontend/Components/BackendCommunication/__tests__/BackendCommunication.test.tsx rename src/Frontend/util/__tests__/{get-attributions-with-resources.test.ts => attribution-utils.test.ts} (83%) diff --git a/src/Frontend/Components/BackendCommunication/__tests__/BackendCommunication.test.tsx b/src/Frontend/Components/BackendCommunication/__tests__/BackendCommunication.test.tsx deleted file mode 100644 index 8f864a24a..000000000 --- a/src/Frontend/Components/BackendCommunication/__tests__/BackendCommunication.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates -// SPDX-FileCopyrightText: TNG Technology Consulting GmbH -// SPDX-FileCopyrightText: Nico Carl -// -// SPDX-License-Identifier: Apache-2.0 -import { Attributions, ExportType } from '../../../../shared/shared-types'; -import { getBomAttributions } from '../BackendCommunication'; - -describe('BackendCommunication', () => { - it('filters the correct BOM attributions', () => { - const testAttributions: Attributions = { - genericAttrib: { id: 'genericAttrib' }, - firstPartyAttrib: { firstParty: true, id: 'firstPartyAttrib' }, - followupAttrib: { followUp: true, id: 'followupAttrib' }, - excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, - firstPartyExcludeAttrib: { - firstParty: true, - excludeFromNotice: true, - id: 'firstPartyExcludeAttrib', - }, - }; - - const detailedBomAttributions = getBomAttributions( - testAttributions, - ExportType.DetailedBom, - ); - expect(detailedBomAttributions).toEqual({ - genericAttrib: { id: 'genericAttrib' }, - excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, - }); - - const compactBomAttributions = getBomAttributions( - testAttributions, - ExportType.CompactBom, - ); - expect(compactBomAttributions).toEqual({ - genericAttrib: { id: 'genericAttrib' }, - }); - - const completeTestAttributions: Attributions = { - completeAttrib: { - attributionConfidence: 1, - comment: 'Test', - packageName: 'Test component', - packageVersion: '', - packageNamespace: 'org.apache.xmlgraphics', - packageType: 'maven', - packagePURLAppendix: - '?repository_url=repo.spring.io/release#everybody/loves/dogs', - url: '', - copyright: '(c) John Doe', - licenseName: '', - licenseText: 'Permission is hereby granted, free of charge, to...', - originIds: [''], - preSelected: true, - id: 'completeAttrib', - }, - }; - - const compactBomCompleteAttributions = getBomAttributions( - completeTestAttributions, - ExportType.CompactBom, - ); - expect(compactBomCompleteAttributions).toEqual(completeTestAttributions); - - const detailedBomCompleteAttributions = getBomAttributions( - completeTestAttributions, - ExportType.DetailedBom, - ); - expect(detailedBomCompleteAttributions).toEqual(completeTestAttributions); - }); -}); diff --git a/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts b/src/Frontend/util/__tests__/attribution-utils.test.ts similarity index 83% rename from src/Frontend/util/__tests__/get-attributions-with-resources.test.ts rename to src/Frontend/util/__tests__/attribution-utils.test.ts index 4bace74ab..13aaf4c94 100644 --- a/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts +++ b/src/Frontend/util/__tests__/attribution-utils.test.ts @@ -5,11 +5,13 @@ import { Attributions, AttributionsToResources, + ExportType, Resources, } from '../../../shared/shared-types'; import { attributionUtils, getAttributionsWithAllChildResourcesWithoutFolders, + getBomAttributions, removeSlashesFromFilesWithChildren, } from '../attribution-utils'; @@ -435,3 +437,68 @@ describe('removeSlashesFromFilesWithChildren', () => { ).toEqual(expectedAttributionsWithResources); }); }); + +describe('getBomAttributions', () => { + it('filters the correct BOM attributions', () => { + const testAttributions: Attributions = { + genericAttrib: { id: 'genericAttrib' }, + firstPartyAttrib: { firstParty: true, id: 'firstPartyAttrib' }, + followupAttrib: { followUp: true, id: 'followupAttrib' }, + excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, + firstPartyExcludeAttrib: { + firstParty: true, + excludeFromNotice: true, + id: 'firstPartyExcludeAttrib', + }, + }; + + const detailedBomAttributions = getBomAttributions( + testAttributions, + ExportType.DetailedBom, + ); + expect(detailedBomAttributions).toEqual({ + genericAttrib: { id: 'genericAttrib' }, + excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, + }); + + const compactBomAttributions = getBomAttributions( + testAttributions, + ExportType.CompactBom, + ); + expect(compactBomAttributions).toEqual({ + genericAttrib: { id: 'genericAttrib' }, + }); + + const completeTestAttributions: Attributions = { + completeAttrib: { + attributionConfidence: 1, + comment: 'Test', + packageName: 'Test component', + packageVersion: '', + packageNamespace: 'org.apache.xmlgraphics', + packageType: 'maven', + packagePURLAppendix: + '?repository_url=repo.spring.io/release#everybody/loves/dogs', + url: '', + copyright: '(c) John Doe', + licenseName: '', + licenseText: 'Permission is hereby granted, free of charge, to...', + originIds: [''], + preSelected: true, + id: 'completeAttrib', + }, + }; + + const compactBomCompleteAttributions = getBomAttributions( + completeTestAttributions, + ExportType.CompactBom, + ); + expect(compactBomCompleteAttributions).toEqual(completeTestAttributions); + + const detailedBomCompleteAttributions = getBomAttributions( + completeTestAttributions, + ExportType.DetailedBom, + ); + expect(detailedBomCompleteAttributions).toEqual(completeTestAttributions); + }); +}); From db7c031dc64cd15c80e25008bce3f7d1be25da9a Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 10:51:50 +0100 Subject: [PATCH 05/26] fix: properly reset state in proceedFromUnsavedPopup --- src/Frontend/state/actions/popup-actions/popup-actions.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index 67c84103f..3c690149d 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -125,11 +125,13 @@ export function proceedFromUnsavedPopup(): AppThunkAction { if (importFileRequest) { dispatch(openPopup(PopupType.ImportDialog, undefined, importFileRequest)); + dispatch(setImportFileRequest(null)); return; } if (exportFileRequest) { dispatch(exportFile(exportFileRequest)); + dispatch(setExportFileRequest(null)); return; } @@ -137,12 +139,6 @@ export function proceedFromUnsavedPopup(): AppThunkAction { if (targetView) { dispatch(navigateToView(targetView)); } - dispatch( - setTemporaryDisplayPackageInfo( - getPackageInfoOfSelectedAttribution(getState()) || - EMPTY_DISPLAY_PACKAGE_INFO, - ), - ); }; } From 65139ea5b81e484f27ece7321d5133b3becc7536 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 11:12:11 +0100 Subject: [PATCH 06/26] test: update popup-actions test for modified existing popup-actions --- .../__tests__/popup-actions.test.ts | 152 +++++++++++++----- 1 file changed, 109 insertions(+), 43 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts index c1ca1de13..8af96c95e 100644 --- a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts +++ b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts @@ -6,6 +6,8 @@ import { Attributions, DiscreteConfidence, + ExportType, + FileType, PackageInfo, Resources, ResourcesToAttributions, @@ -25,6 +27,9 @@ import { getTemporaryDisplayPackageInfo, } from '../../../selectors/resource-selectors'; import { + getExportFileRequest, + getImportFileRequest, + getOpenFileRequest, getOpenPopup, getSelectedView, getTargetView, @@ -36,13 +41,18 @@ import { import { setSelectedAttributionId, setSelectedResourceId, + setTargetSelectedAttributionId, setTargetSelectedResourceId, } from '../../resource-actions/audit-view-simple-actions'; +import * as exportActions from '../../resource-actions/export-actions'; import { loadFromFile } from '../../resource-actions/load-actions'; import { savePackageInfo } from '../../resource-actions/save-actions'; import { navigateToView, openPopup, + setExportFileRequest, + setImportFileRequest, + setOpenFileRequest, setTargetView, } from '../../view-actions/view-actions'; import { @@ -303,64 +313,120 @@ describe('The actions checking for unsaved changes', () => { }); }); -describe('The actions called from the unsaved popup', () => { - describe('navigateToTargetResourceOrAttribution', () => { - function prepareTestState(): State { - const testStore = createAppStore(); - testStore.dispatch( - setResources({ selectedResource: 1, newSelectedResource: 1 }), - ); - testStore.dispatch(setSelectedResourceId('selectedResource')); - testStore.dispatch( - setTemporaryDisplayPackageInfo({ - packageName: 'Test', - id: faker.string.uuid(), - }), - ); - testStore.dispatch(navigateToView(View.Report)); - testStore.dispatch(setTargetView(View.Audit)); - testStore.dispatch(openPopup(PopupType.NotSavedPopup)); - testStore.dispatch(setTargetSelectedResourceId('newSelectedResource')); - testStore.dispatch(proceedFromUnsavedPopup()); - return testStore.getState(); - } - - it('closes popup', () => { - const state = prepareTestState(); - expect(getOpenPopup(state)).toBeFalsy(); - }); +describe('proceedFromUnsavedPopup', () => { + function prepareTestState(): State { + const testStore = createAppStore(); + testStore.dispatch( + setResources({ selectedResource: 1, newSelectedResource: 1 }), + ); + testStore.dispatch(setSelectedResourceId('selectedResource')); + testStore.dispatch( + setTemporaryDisplayPackageInfo({ + packageName: 'Test', + id: faker.string.uuid(), + }), + ); + testStore.dispatch(navigateToView(View.Report)); + testStore.dispatch(setTargetView(View.Audit)); + testStore.dispatch(openPopup(PopupType.NotSavedPopup)); + testStore.dispatch(setTargetSelectedResourceId('newSelectedResource')); + testStore.dispatch(proceedFromUnsavedPopup()); + return testStore.getState(); + } - it('sets the view', () => { - const state = prepareTestState(); - expect(getSelectedView(state)).toBe(View.Audit); - }); + it('closes popup', () => { + const state = prepareTestState(); + expect(getOpenPopup(state)).toBeFalsy(); + }); - it('sets targetSelectedResourceOrAttribution', () => { - const state = prepareTestState(); - expect(getSelectedResourceId(state)).toBe('newSelectedResource'); - }); + it('sets the view', () => { + const state = prepareTestState(); + expect(getSelectedView(state)).toBe(View.Audit); + }); - it('sets temporaryDisplayPackageInfo', () => { - const state = prepareTestState(); - expect(getTemporaryDisplayPackageInfo(state)).toMatchObject({}); - }); + it('sets targetSelectedResourceOrAttribution', () => { + const state = prepareTestState(); + expect(getSelectedResourceId(state)).toBe('newSelectedResource'); + }); - it('does not save temporaryDisplayPackageInfo', () => { - const state = prepareTestState(); - expect(getManualAttributions(state)).toMatchObject({}); - }); + it('sets temporaryDisplayPackageInfo', () => { + const state = prepareTestState(); + expect(getTemporaryDisplayPackageInfo(state)).toMatchObject({}); + }); + + it('does not save temporaryDisplayPackageInfo', () => { + const state = prepareTestState(); + expect(getManualAttributions(state)).toMatchObject({}); + }); + + it('proceeds with open file request', () => { + jest.spyOn(window.electronAPI, 'openFile').mockResolvedValue({}); + + const testStore = createAppStore(); + testStore.dispatch(setOpenFileRequest(true)); + testStore.dispatch(openPopup(PopupType.NotSavedPopup)); + testStore.dispatch(proceedFromUnsavedPopup()); + + expect(window.electronAPI.openFile).toHaveBeenCalled(); + expect(getOpenFileRequest(testStore.getState())).toBe(false); + }); + + it('proceeds with import file request', () => { + const testStore = createAppStore(); + testStore.dispatch( + setImportFileRequest({ + fileType: FileType.LEGACY_OPOSSUM, + extensions: [], + name: '', + }), + ); + testStore.dispatch(openPopup(PopupType.NotSavedPopup)); + testStore.dispatch(proceedFromUnsavedPopup()); + + expect(getOpenPopup(testStore.getState())?.popup).toBe( + PopupType.ImportDialog, + ); + expect(getImportFileRequest(testStore.getState())).toBeNull(); + }); + + it('proceeds with export file request', () => { + jest.spyOn(exportActions, 'exportFile').mockReturnValue(() => {}); + + const testStore = createAppStore(); + testStore.dispatch(setExportFileRequest(ExportType.FollowUp)); + testStore.dispatch(openPopup(PopupType.NotSavedPopup)); + testStore.dispatch(proceedFromUnsavedPopup()); + + expect(exportActions.exportFile).toHaveBeenCalledWith(ExportType.FollowUp); + expect(getExportFileRequest(testStore.getState())).toBeNull(); }); }); -describe('closePopupAndReopenEditAttributionPopupIfItWasPreviouslyOpen', () => { +describe('closePopupAndUnsetTargets', () => { it('closes popup and unsets targets', () => { const testStore = createAppStore(); testStore.dispatch(openPopup(PopupType.NotSavedPopup)); + testStore.dispatch(setTargetView(View.Audit)); + testStore.dispatch(setTargetSelectedResourceId('resourceID')); + testStore.dispatch(setTargetSelectedAttributionId('attributionID')); + testStore.dispatch(setOpenFileRequest(true)); + testStore.dispatch( + setImportFileRequest({ + fileType: FileType.LEGACY_OPOSSUM, + extensions: [], + name: '', + }), + ); + testStore.dispatch(setExportFileRequest(ExportType.FollowUp)); testStore.dispatch(closePopupAndUnsetTargets()); + expect(getTargetView(testStore.getState())).toBeNull(); expect(getTargetSelectedResourceId(testStore.getState())).toBe(''); expect(getTargetSelectedAttributionId(testStore.getState())).toBe(''); + expect(getOpenFileRequest(testStore.getState())).toBe(false); + expect(getImportFileRequest(testStore.getState())).toBeNull(); + expect(getExportFileRequest(testStore.getState())).toBeNull(); expect(getOpenPopup(testStore.getState())).toBeNull(); }); }); From d9ce26b370a6026a9eef16c0b3a638a60c5b2951 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 11:29:51 +0100 Subject: [PATCH 07/26] refactor: extract separate function for conditionally opening NotSavedPopup --- .../BackendCommunication.tsx | 4 +- .../actions/popup-actions/popup-actions.ts | 51 ++++++++++++------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index 1cdccc67e..994436410 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -16,7 +16,7 @@ import { ROOT_PATH } from '../../shared-constants'; import { exportFileWithUnsavedCheck, openFileWithUnsavedCheck, - showImportDialog, + showImportDialogWithUnsavedCheck, } from '../../state/actions/popup-actions/popup-actions'; import { resetResourceState, @@ -142,7 +142,7 @@ export const BackendCommunication: React.FC = () => { ); useIpcRenderer( AllowedFrontendChannels.ImportFileShowDialog, - (_, fileFormat) => dispatch(showImportDialog(fileFormat)), + (_, fileFormat) => dispatch(showImportDialogWithUnsavedCheck(fileFormat)), [dispatch], ); diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index 3c690149d..27e385031 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -154,37 +154,54 @@ export function closePopupAndUnsetTargets(): AppThunkAction { }; } -export function showImportDialog(fileFormat: FileFormatInfo): AppThunkAction { +function actionWithUnsavedCheck( + executeAction: () => void, + requestAction: () => void, +): AppThunkAction { return (dispatch, getState) => { if (getIsPackageInfoModified(getState())) { - dispatch(setImportFileRequest(fileFormat)); + requestAction(); dispatch(openPopup(PopupType.NotSavedPopup)); } else { - dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)); + executeAction(); } }; } +export function showImportDialogWithUnsavedCheck( + fileFormat: FileFormatInfo, +): AppThunkAction { + return (dispatch, _) => { + dispatch( + actionWithUnsavedCheck( + () => + dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)), + () => dispatch(setImportFileRequest(fileFormat)), + ), + ); + }; +} + export function openFileWithUnsavedCheck(): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { - dispatch(setOpenFileRequest(true)); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { - void window.electronAPI.openFile(); - } + return (dispatch, _) => { + dispatch( + actionWithUnsavedCheck( + () => void window.electronAPI.openFile(), + () => dispatch(setOpenFileRequest(true)), + ), + ); }; } export function exportFileWithUnsavedCheck( exportType: ExportType, ): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { - dispatch(setExportFileRequest(exportType)); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { - dispatch(exportFile(exportType)); - } + return (dispatch, _) => { + dispatch( + actionWithUnsavedCheck( + () => dispatch(exportFile(exportType)), + () => dispatch(setExportFileRequest(exportType)), + ), + ); }; } From 75f5970e36a04e50fbb69c6052059e9a97058f31 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 11:58:18 +0100 Subject: [PATCH 08/26] fix: fix failing e2e test and small styling problem --- src/ElectronBackend/main/listeners.ts | 2 +- src/Frontend/Components/ImportDialog/ImportDialog.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ElectronBackend/main/listeners.ts b/src/ElectronBackend/main/listeners.ts index 1e1cf3f73..d07bde267 100644 --- a/src/ElectronBackend/main/listeners.ts +++ b/src/ElectronBackend/main/listeners.ts @@ -195,7 +195,7 @@ export function getImportFileConvertAndLoadListener( opossumFilePath: string, ) => { if (!resourceFilePath.trim() || !fs.existsSync(resourceFilePath)) { - throw new Error('Input file does not exists'); + throw new Error('Input file does not exist'); } if (!opossumFilePath.trim()) { diff --git a/src/Frontend/Components/ImportDialog/ImportDialog.tsx b/src/Frontend/Components/ImportDialog/ImportDialog.tsx index 2ef031f3f..33656d9b4 100644 --- a/src/Frontend/Components/ImportDialog/ImportDialog.tsx +++ b/src/Frontend/Components/ImportDialog/ImportDialog.tsx @@ -89,6 +89,8 @@ export const ImportDialog: React.FC = ({ fileFormat }) => { if (success) { dispatch(closePopup()); } + + setIsLoading(false); } return ( @@ -127,9 +129,9 @@ export const ImportDialog: React.FC = ({ fileFormat }) => { Date: Thu, 13 Feb 2025 12:26:17 +0100 Subject: [PATCH 09/26] fix: fix failing e2e test --- .../actions/popup-actions/popup-actions.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index 27e385031..059182f7e 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -102,14 +102,6 @@ export function setSelectedResourceIdOrOpenUnsavedPopup( export function proceedFromUnsavedPopup(): AppThunkAction { return (dispatch, getState) => { - // discard changes - dispatch( - setTemporaryDisplayPackageInfo( - getPackageInfoOfSelectedAttribution(getState()) || - EMPTY_DISPLAY_PACKAGE_INFO, - ), - ); - const targetView = getTargetView(getState()); const openFileRequest = getOpenFileRequest(getState()); const importFileRequest = getImportFileRequest(getState()); @@ -120,33 +112,37 @@ export function proceedFromUnsavedPopup(): AppThunkAction { if (openFileRequest) { void window.electronAPI.openFile(); dispatch(setOpenFileRequest(false)); - return; } if (importFileRequest) { dispatch(openPopup(PopupType.ImportDialog, undefined, importFileRequest)); dispatch(setImportFileRequest(null)); - return; } if (exportFileRequest) { dispatch(exportFile(exportFileRequest)); dispatch(setExportFileRequest(null)); - return; } dispatch(setSelectedResourceOrAttributionIdToTargetValue()); if (targetView) { dispatch(navigateToView(targetView)); } + + dispatch( + setTemporaryDisplayPackageInfo( + getPackageInfoOfSelectedAttribution(getState()) || + EMPTY_DISPLAY_PACKAGE_INFO, + ), + ); }; } export function closePopupAndUnsetTargets(): AppThunkAction { return (dispatch) => { dispatch(setTargetView(null)); - dispatch(setTargetSelectedResourceId('')); - dispatch(setTargetSelectedAttributionId('')); + dispatch(setTargetSelectedResourceId(null)); + dispatch(setTargetSelectedAttributionId(null)); dispatch(closePopup()); dispatch(setOpenFileRequest(false)); dispatch(setImportFileRequest(null)); From 153883307571476cfa3b322f29cc9f2eac08af42 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 13:57:30 +0100 Subject: [PATCH 10/26] refactor: change back accidentally changed function name --- .../state/actions/resource-actions/export-actions.ts | 4 ++-- src/Frontend/util/__tests__/attribution-utils.test.ts | 9 ++++++--- src/Frontend/util/attribution-utils.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Frontend/state/actions/resource-actions/export-actions.ts b/src/Frontend/state/actions/resource-actions/export-actions.ts index b958455cf..386eee09e 100644 --- a/src/Frontend/state/actions/resource-actions/export-actions.ts +++ b/src/Frontend/state/actions/resource-actions/export-actions.ts @@ -12,8 +12,8 @@ import { } from '../../../../shared/shared-types'; import { State } from '../../../types/types'; import { - attributionUtils, getAttributionsWithAllChildResourcesWithoutFolders, + getAttributionsWithResources, getBomAttributions, removeSlashesFromFilesWithChildren, } from '../../../util/attribution-utils'; @@ -112,7 +112,7 @@ function getDetailedBomExportListener(state: State): void { ExportType.DetailedBom, ); - const bomAttributionsWithResources = attributionUtils( + const bomAttributionsWithResources = getAttributionsWithResources( bomAttributions, getManualData(state).attributionsToResources, ); diff --git a/src/Frontend/util/__tests__/attribution-utils.test.ts b/src/Frontend/util/__tests__/attribution-utils.test.ts index 13aaf4c94..f04b456f0 100644 --- a/src/Frontend/util/__tests__/attribution-utils.test.ts +++ b/src/Frontend/util/__tests__/attribution-utils.test.ts @@ -9,8 +9,8 @@ import { Resources, } from '../../../shared/shared-types'; import { - attributionUtils, getAttributionsWithAllChildResourcesWithoutFolders, + getAttributionsWithResources, getBomAttributions, removeSlashesFromFilesWithChildren, } from '../attribution-utils'; @@ -48,12 +48,15 @@ describe('getAttributionsWithResources', () => { }; expect( - attributionUtils(testAttributions, testAttributionsToResources), + getAttributionsWithResources( + testAttributions, + testAttributionsToResources, + ), ).toEqual(expectedAttributionsWithResources); }); it('returns attributions with resources for empty attributions', () => { - expect(attributionUtils({}, {})).toEqual({}); + expect(getAttributionsWithResources({}, {})).toEqual({}); }); }); diff --git a/src/Frontend/util/attribution-utils.ts b/src/Frontend/util/attribution-utils.ts index 1b863f2eb..efc9d7109 100644 --- a/src/Frontend/util/attribution-utils.ts +++ b/src/Frontend/util/attribution-utils.ts @@ -18,7 +18,7 @@ import { isIdOfResourceWithChildren, } from './can-resource-have-children'; -export function attributionUtils( +export function getAttributionsWithResources( attributions: Attributions, attributionsToResources: AttributionsToResources, ): Attributions { From f1f4c6668138044ab7f9d5bff9e53badf970a9c3 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 13:59:41 +0100 Subject: [PATCH 11/26] test: update test for popup-actions to reflect recent changes --- .../actions/popup-actions/__tests__/popup-actions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts index 8af96c95e..013c466f2 100644 --- a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts +++ b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts @@ -422,8 +422,8 @@ describe('closePopupAndUnsetTargets', () => { testStore.dispatch(closePopupAndUnsetTargets()); expect(getTargetView(testStore.getState())).toBeNull(); - expect(getTargetSelectedResourceId(testStore.getState())).toBe(''); - expect(getTargetSelectedAttributionId(testStore.getState())).toBe(''); + expect(getTargetSelectedResourceId(testStore.getState())).toBeNull(); + expect(getTargetSelectedAttributionId(testStore.getState())).toBeNull(); expect(getOpenFileRequest(testStore.getState())).toBe(false); expect(getImportFileRequest(testStore.getState())).toBeNull(); expect(getExportFileRequest(testStore.getState())).toBeNull(); From f9c1b25e2826980095fe81e06d3cbd1d20f674f8 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 14:21:15 +0100 Subject: [PATCH 12/26] refactor: factor out more common logic regarding unsaved checks --- .../actions/popup-actions/popup-actions.ts | 75 ++++++++----------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index 059182f7e..264937ae7 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -44,60 +44,63 @@ import { setTargetView, } from '../view-actions/view-actions'; -export function navigateToSelectedPathOrOpenUnsavedPopup( - resourcePath: string, +function withUnsavedCheck( + executeImmediately: AppThunkAction, + requestContinuation: AppThunkAction, ): AppThunkAction { return (dispatch, getState) => { if (getIsPackageInfoModified(getState())) { - dispatch(setTargetSelectedResourceId(resourcePath)); + dispatch(requestContinuation); dispatch(openPopup(PopupType.NotSavedPopup)); } else { - dispatch(openResourceInResourceBrowser(resourcePath)); + dispatch(executeImmediately); } }; } +export function navigateToSelectedPathOrOpenUnsavedPopup( + resourcePath: string, +): AppThunkAction { + return withUnsavedCheck( + (dispatch) => dispatch(openResourceInResourceBrowser(resourcePath)), + (dispatch) => dispatch(setTargetSelectedResourceId(resourcePath)), + ); +} + export function changeSelectedAttributionOrOpenUnsavedPopup( packageInfo: PackageInfo | null, ): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { - dispatch(setTargetSelectedAttributionId(packageInfo?.id || '')); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { + return withUnsavedCheck( + (dispatch) => { dispatch(setSelectedAttributionId(packageInfo?.id ?? '')); dispatch( setTemporaryDisplayPackageInfo( packageInfo || EMPTY_DISPLAY_PACKAGE_INFO, ), ); - } - }; + }, + (dispatch) => + dispatch(setTargetSelectedAttributionId(packageInfo?.id || '')), + ); } export function setViewOrOpenUnsavedPopup(selectedView: View): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { + return withUnsavedCheck( + (dispatch) => dispatch(navigateToView(selectedView)), + (dispatch, getState) => { dispatch(setTargetView(selectedView)); dispatch(setTargetSelectedResourceId(getSelectedResourceId(getState()))); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { - dispatch(navigateToView(selectedView)); - } - }; + }, + ); } export function setSelectedResourceIdOrOpenUnsavedPopup( resourceId: string, ): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { - dispatch(setTargetSelectedResourceId(resourceId)); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { - dispatch(setSelectedResourceId(resourceId)); - } - }; + return withUnsavedCheck( + (dispatch) => dispatch(setSelectedResourceId(resourceId)), + (dispatch) => dispatch(setTargetSelectedResourceId(resourceId)), + ); } export function proceedFromUnsavedPopup(): AppThunkAction { @@ -150,26 +153,12 @@ export function closePopupAndUnsetTargets(): AppThunkAction { }; } -function actionWithUnsavedCheck( - executeAction: () => void, - requestAction: () => void, -): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { - requestAction(); - dispatch(openPopup(PopupType.NotSavedPopup)); - } else { - executeAction(); - } - }; -} - export function showImportDialogWithUnsavedCheck( fileFormat: FileFormatInfo, ): AppThunkAction { return (dispatch, _) => { dispatch( - actionWithUnsavedCheck( + withUnsavedCheck( () => dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)), () => dispatch(setImportFileRequest(fileFormat)), @@ -181,7 +170,7 @@ export function showImportDialogWithUnsavedCheck( export function openFileWithUnsavedCheck(): AppThunkAction { return (dispatch, _) => { dispatch( - actionWithUnsavedCheck( + withUnsavedCheck( () => void window.electronAPI.openFile(), () => dispatch(setOpenFileRequest(true)), ), @@ -194,7 +183,7 @@ export function exportFileWithUnsavedCheck( ): AppThunkAction { return (dispatch, _) => { dispatch( - actionWithUnsavedCheck( + withUnsavedCheck( () => dispatch(exportFile(exportType)), () => dispatch(setExportFileRequest(exportType)), ), From 204af1ff05fe1cb49a1d34077672d76351fc6a0c Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Thu, 13 Feb 2025 14:31:58 +0100 Subject: [PATCH 13/26] test: e2e test that import dialog works with NotSavedPopup --- src/e2e-tests/__tests__/import-dialog.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/e2e-tests/__tests__/import-dialog.test.ts b/src/e2e-tests/__tests__/import-dialog.test.ts index 421da8c2a..20d6b3e4c 100644 --- a/src/e2e-tests/__tests__/import-dialog.test.ts +++ b/src/e2e-tests/__tests__/import-dialog.test.ts @@ -37,11 +37,13 @@ test('opens, displays and closes import dialog', async ({ await importDialog.assert.titleIsHidden(); }); -test('imports legacy opossum file', async ({ +test('imports legacy opossum file and works with NotSavedPopup', async ({ menuBar, importDialog, resourcesTree, window, + attributionDetails, + notSavedPopup, }) => { await stubDialog(window.app, 'showOpenDialogSync', [ importDialog.legacyFilePath, @@ -61,6 +63,15 @@ test('imports legacy opossum file', async ({ await importDialog.assert.titleIsHidden(); await resourcesTree.assert.resourceIsVisible(resourceName); + + const comment = faker.lorem.sentences(); + await resourcesTree.goto(resourceName); + await attributionDetails.attributionForm.comment.fill(comment); + + await menuBar.openImportLegacyOpossumFile(); + await notSavedPopup.assert.isVisible(); + await notSavedPopup.discardButton.click(); + await importDialog.assert.titleIsVisible(); }); test('imports scancode file', async ({ From bf908dbb16c16b3bf6130b3635f12d9b13b798f0 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 09:31:46 +0100 Subject: [PATCH 14/26] refactor: make usage of withUnsavedCheck more consistent --- .../BackendCommunication.tsx | 12 ++-- src/Frontend/Components/TopBar/TopBar.tsx | 4 +- .../actions/popup-actions/popup-actions.ts | 64 ++++++++----------- 3 files changed, 34 insertions(+), 46 deletions(-) diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index 994436410..4431edcc4 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -14,9 +14,9 @@ import { import { PopupType } from '../../enums/enums'; import { ROOT_PATH } from '../../shared-constants'; import { - exportFileWithUnsavedCheck, - openFileWithUnsavedCheck, - showImportDialogWithUnsavedCheck, + exportFileOrOpenUnsavedPopup, + openFileOrOpenUnsavedPopup, + showImportDialogOrOpenUnsavedPopup, } from '../../state/actions/popup-actions/popup-actions'; import { resetResourceState, @@ -127,7 +127,7 @@ export const BackendCommunication: React.FC = () => { ); useIpcRenderer( AllowedFrontendChannels.ExportFileRequest, - (_, exportType) => dispatch(exportFileWithUnsavedCheck(exportType)), + (_, exportType) => dispatch(exportFileOrOpenUnsavedPopup(exportType)), [dispatch], ); useIpcRenderer( @@ -137,12 +137,12 @@ export const BackendCommunication: React.FC = () => { ); useIpcRenderer( AllowedFrontendChannels.OpenFileWithUnsavedCheck, - () => dispatch(openFileWithUnsavedCheck()), + () => dispatch(openFileOrOpenUnsavedPopup()), [dispatch], ); useIpcRenderer( AllowedFrontendChannels.ImportFileShowDialog, - (_, fileFormat) => dispatch(showImportDialogWithUnsavedCheck(fileFormat)), + (_, fileFormat) => dispatch(showImportDialogOrOpenUnsavedPopup(fileFormat)), [dispatch], ); diff --git a/src/Frontend/Components/TopBar/TopBar.tsx b/src/Frontend/Components/TopBar/TopBar.tsx index ce137b03a..6cb2ddace 100644 --- a/src/Frontend/Components/TopBar/TopBar.tsx +++ b/src/Frontend/Components/TopBar/TopBar.tsx @@ -14,7 +14,7 @@ import commitInfo from '../../../commitInfo.json'; import { View } from '../../enums/enums'; import { OpossumColors } from '../../shared-styles'; import { - openFileWithUnsavedCheck, + openFileOrOpenUnsavedPopup, setViewOrOpenUnsavedPopup, } from '../../state/actions/popup-actions/popup-actions'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; @@ -91,7 +91,7 @@ export const TopBar: React.FC = () => { } function handleOpenFileClick(): void { - dispatch(openFileWithUnsavedCheck()); + dispatch(openFileOrOpenUnsavedPopup()); } return ( diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index 264937ae7..f15d7c536 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -103,6 +103,32 @@ export function setSelectedResourceIdOrOpenUnsavedPopup( ); } +export function showImportDialogOrOpenUnsavedPopup( + fileFormat: FileFormatInfo, +): AppThunkAction { + return withUnsavedCheck( + (dispatch) => + dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)), + (dispatch) => dispatch(setImportFileRequest(fileFormat)), + ); +} + +export function openFileOrOpenUnsavedPopup(): AppThunkAction { + return withUnsavedCheck( + () => void window.electronAPI.openFile(), + (dispatch) => dispatch(setOpenFileRequest(true)), + ); +} + +export function exportFileOrOpenUnsavedPopup( + exportType: ExportType, +): AppThunkAction { + return withUnsavedCheck( + (dispatch) => dispatch(exportFile(exportType)), + (dispatch) => dispatch(setExportFileRequest(exportType)), + ); +} + export function proceedFromUnsavedPopup(): AppThunkAction { return (dispatch, getState) => { const targetView = getTargetView(getState()); @@ -152,41 +178,3 @@ export function closePopupAndUnsetTargets(): AppThunkAction { dispatch(setExportFileRequest(null)); }; } - -export function showImportDialogWithUnsavedCheck( - fileFormat: FileFormatInfo, -): AppThunkAction { - return (dispatch, _) => { - dispatch( - withUnsavedCheck( - () => - dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)), - () => dispatch(setImportFileRequest(fileFormat)), - ), - ); - }; -} - -export function openFileWithUnsavedCheck(): AppThunkAction { - return (dispatch, _) => { - dispatch( - withUnsavedCheck( - () => void window.electronAPI.openFile(), - () => dispatch(setOpenFileRequest(true)), - ), - ); - }; -} - -export function exportFileWithUnsavedCheck( - exportType: ExportType, -): AppThunkAction { - return (dispatch, _) => { - dispatch( - withUnsavedCheck( - () => dispatch(exportFile(exportType)), - () => dispatch(setExportFileRequest(exportType)), - ), - ); - }; -} From 21702bb1f7a5c54ea2ad2f6939fe8830055ab9d1 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 09:46:12 +0100 Subject: [PATCH 15/26] refactor: clean up some unit tests --- .../util/__tests__/attribution-utils.test.ts | 60 +++++-------------- src/e2e-tests/__tests__/import-dialog.test.ts | 2 +- 2 files changed, 16 insertions(+), 46 deletions(-) diff --git a/src/Frontend/util/__tests__/attribution-utils.test.ts b/src/Frontend/util/__tests__/attribution-utils.test.ts index f04b456f0..0d665ef6f 100644 --- a/src/Frontend/util/__tests__/attribution-utils.test.ts +++ b/src/Frontend/util/__tests__/attribution-utils.test.ts @@ -442,19 +442,19 @@ describe('removeSlashesFromFilesWithChildren', () => { }); describe('getBomAttributions', () => { - it('filters the correct BOM attributions', () => { - const testAttributions: Attributions = { - genericAttrib: { id: 'genericAttrib' }, - firstPartyAttrib: { firstParty: true, id: 'firstPartyAttrib' }, - followupAttrib: { followUp: true, id: 'followupAttrib' }, - excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, - firstPartyExcludeAttrib: { - firstParty: true, - excludeFromNotice: true, - id: 'firstPartyExcludeAttrib', - }, - }; - + const testAttributions: Attributions = { + genericAttrib: { id: 'genericAttrib' }, + firstPartyAttrib: { firstParty: true, id: 'firstPartyAttrib' }, + followupAttrib: { followUp: true, id: 'followupAttrib' }, + excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, + firstPartyExcludeAttrib: { + firstParty: true, + excludeFromNotice: true, + id: 'firstPartyExcludeAttrib', + }, + }; + + it('filters out attributions marked as follow up or first party', () => { const detailedBomAttributions = getBomAttributions( testAttributions, ExportType.DetailedBom, @@ -463,7 +463,9 @@ describe('getBomAttributions', () => { genericAttrib: { id: 'genericAttrib' }, excludeAttrib: { excludeFromNotice: true, id: 'excludeAttrib' }, }); + }); + it('filters out attributions excluded from notice for compact BOM export', () => { const compactBomAttributions = getBomAttributions( testAttributions, ExportType.CompactBom, @@ -471,37 +473,5 @@ describe('getBomAttributions', () => { expect(compactBomAttributions).toEqual({ genericAttrib: { id: 'genericAttrib' }, }); - - const completeTestAttributions: Attributions = { - completeAttrib: { - attributionConfidence: 1, - comment: 'Test', - packageName: 'Test component', - packageVersion: '', - packageNamespace: 'org.apache.xmlgraphics', - packageType: 'maven', - packagePURLAppendix: - '?repository_url=repo.spring.io/release#everybody/loves/dogs', - url: '', - copyright: '(c) John Doe', - licenseName: '', - licenseText: 'Permission is hereby granted, free of charge, to...', - originIds: [''], - preSelected: true, - id: 'completeAttrib', - }, - }; - - const compactBomCompleteAttributions = getBomAttributions( - completeTestAttributions, - ExportType.CompactBom, - ); - expect(compactBomCompleteAttributions).toEqual(completeTestAttributions); - - const detailedBomCompleteAttributions = getBomAttributions( - completeTestAttributions, - ExportType.DetailedBom, - ); - expect(detailedBomCompleteAttributions).toEqual(completeTestAttributions); }); }); diff --git a/src/e2e-tests/__tests__/import-dialog.test.ts b/src/e2e-tests/__tests__/import-dialog.test.ts index 20d6b3e4c..83e6be5c5 100644 --- a/src/e2e-tests/__tests__/import-dialog.test.ts +++ b/src/e2e-tests/__tests__/import-dialog.test.ts @@ -37,7 +37,7 @@ test('opens, displays and closes import dialog', async ({ await importDialog.assert.titleIsHidden(); }); -test('imports legacy opossum file and works with NotSavedPopup', async ({ +test('imports legacy opossum file and checks for unsaved changes', async ({ menuBar, importDialog, resourcesTree, From 3a3450c2cc70ff7716b7c0027a55f78f13f052c8 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 10:06:23 +0100 Subject: [PATCH 16/26] test: make unit test for NotSavedPopup on import request more precise --- .../__tests__/popup-actions.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts index 013c466f2..30fded48c 100644 --- a/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts +++ b/src/Frontend/state/actions/popup-actions/__tests__/popup-actions.test.ts @@ -373,19 +373,20 @@ describe('proceedFromUnsavedPopup', () => { it('proceeds with import file request', () => { const testStore = createAppStore(); - testStore.dispatch( - setImportFileRequest({ - fileType: FileType.LEGACY_OPOSSUM, - extensions: [], - name: '', - }), - ); + const fileFormat = { + fileType: FileType.LEGACY_OPOSSUM, + extensions: [], + name: '', + }; + testStore.dispatch(setImportFileRequest(fileFormat)); testStore.dispatch(openPopup(PopupType.NotSavedPopup)); testStore.dispatch(proceedFromUnsavedPopup()); - expect(getOpenPopup(testStore.getState())?.popup).toBe( - PopupType.ImportDialog, - ); + expect(getOpenPopup(testStore.getState())).toStrictEqual({ + popup: PopupType.ImportDialog, + attributionId: undefined, + fileFormat, + }); expect(getImportFileRequest(testStore.getState())).toBeNull(); }); From d13a6da7e85721ec47648c15e976fa9e6fe12420 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 10:12:36 +0100 Subject: [PATCH 17/26] refactor: turn withUnsavedCheck parameters into an object to have named parameters --- .../actions/popup-actions/popup-actions.ts | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index f15d7c536..da7b7a455 100644 --- a/src/Frontend/state/actions/popup-actions/popup-actions.ts +++ b/src/Frontend/state/actions/popup-actions/popup-actions.ts @@ -44,10 +44,13 @@ import { setTargetView, } from '../view-actions/view-actions'; -function withUnsavedCheck( - executeImmediately: AppThunkAction, - requestContinuation: AppThunkAction, -): AppThunkAction { +function withUnsavedCheck({ + executeImmediately, + requestContinuation, +}: { + executeImmediately: AppThunkAction; + requestContinuation: AppThunkAction; +}): AppThunkAction { return (dispatch, getState) => { if (getIsPackageInfoModified(getState())) { dispatch(requestContinuation); @@ -61,17 +64,19 @@ function withUnsavedCheck( export function navigateToSelectedPathOrOpenUnsavedPopup( resourcePath: string, ): AppThunkAction { - return withUnsavedCheck( - (dispatch) => dispatch(openResourceInResourceBrowser(resourcePath)), - (dispatch) => dispatch(setTargetSelectedResourceId(resourcePath)), - ); + return withUnsavedCheck({ + executeImmediately: (dispatch) => + dispatch(openResourceInResourceBrowser(resourcePath)), + requestContinuation: (dispatch) => + dispatch(setTargetSelectedResourceId(resourcePath)), + }); } export function changeSelectedAttributionOrOpenUnsavedPopup( packageInfo: PackageInfo | null, ): AppThunkAction { - return withUnsavedCheck( - (dispatch) => { + return withUnsavedCheck({ + executeImmediately: (dispatch) => { dispatch(setSelectedAttributionId(packageInfo?.id ?? '')); dispatch( setTemporaryDisplayPackageInfo( @@ -79,54 +84,58 @@ export function changeSelectedAttributionOrOpenUnsavedPopup( ), ); }, - (dispatch) => + requestContinuation: (dispatch) => dispatch(setTargetSelectedAttributionId(packageInfo?.id || '')), - ); + }); } export function setViewOrOpenUnsavedPopup(selectedView: View): AppThunkAction { - return withUnsavedCheck( - (dispatch) => dispatch(navigateToView(selectedView)), - (dispatch, getState) => { + return withUnsavedCheck({ + executeImmediately: (dispatch) => dispatch(navigateToView(selectedView)), + requestContinuation: (dispatch, getState) => { dispatch(setTargetView(selectedView)); dispatch(setTargetSelectedResourceId(getSelectedResourceId(getState()))); }, - ); + }); } export function setSelectedResourceIdOrOpenUnsavedPopup( resourceId: string, ): AppThunkAction { - return withUnsavedCheck( - (dispatch) => dispatch(setSelectedResourceId(resourceId)), - (dispatch) => dispatch(setTargetSelectedResourceId(resourceId)), - ); + return withUnsavedCheck({ + executeImmediately: (dispatch) => + dispatch(setSelectedResourceId(resourceId)), + requestContinuation: (dispatch) => + dispatch(setTargetSelectedResourceId(resourceId)), + }); } export function showImportDialogOrOpenUnsavedPopup( fileFormat: FileFormatInfo, ): AppThunkAction { - return withUnsavedCheck( - (dispatch) => + return withUnsavedCheck({ + executeImmediately: (dispatch) => dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)), - (dispatch) => dispatch(setImportFileRequest(fileFormat)), - ); + requestContinuation: (dispatch) => + dispatch(setImportFileRequest(fileFormat)), + }); } export function openFileOrOpenUnsavedPopup(): AppThunkAction { - return withUnsavedCheck( - () => void window.electronAPI.openFile(), - (dispatch) => dispatch(setOpenFileRequest(true)), - ); + return withUnsavedCheck({ + executeImmediately: () => void window.electronAPI.openFile(), + requestContinuation: (dispatch) => dispatch(setOpenFileRequest(true)), + }); } export function exportFileOrOpenUnsavedPopup( exportType: ExportType, ): AppThunkAction { - return withUnsavedCheck( - (dispatch) => dispatch(exportFile(exportType)), - (dispatch) => dispatch(setExportFileRequest(exportType)), - ); + return withUnsavedCheck({ + executeImmediately: (dispatch) => dispatch(exportFile(exportType)), + requestContinuation: (dispatch) => + dispatch(setExportFileRequest(exportType)), + }); } export function proceedFromUnsavedPopup(): AppThunkAction { From cd9e09b4606cbdbeab68fcb3e4dfff6d7d056a34 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 12:04:17 +0100 Subject: [PATCH 18/26] refactor: redirect isLoading and log message events through redux store * we want to be able to trigger these events from the frontend as well --- src/ElectronBackend/main/logger.ts | 5 ++- .../BackendCommunication.tsx | 21 ++++++++-- .../Components/ImportDialog/ImportDialog.tsx | 30 +++++++------- .../Components/ProcessPopup/ProcessPopup.tsx | 40 +++++++------------ .../state/actions/view-actions/types.ts | 22 +++++++++- .../actions/view-actions/view-actions.ts | 18 ++++++++- src/Frontend/state/reducers/view-reducer.ts | 18 ++++++++- src/Frontend/state/selectors/view-selector.ts | 10 ++++- 8 files changed, 115 insertions(+), 49 deletions(-) diff --git a/src/ElectronBackend/main/logger.ts b/src/ElectronBackend/main/logger.ts index 2ecdbf864..abe80e142 100644 --- a/src/ElectronBackend/main/logger.ts +++ b/src/ElectronBackend/main/logger.ts @@ -13,7 +13,10 @@ class Logger { message: string, { level }: Pick, ): void { - BrowserWindow.getFocusedWindow()?.webContents.send( + // NOTE: there are situations where BrowserWindow.getAllWindows() returns a + // non-empty array but BrowserWindow.getFocusedWindow() returns null. + // Thus, using getAllWindows here is more robust than getFocusedWindow + BrowserWindow.getAllWindows()[0]?.webContents.send( AllowedFrontendChannels.Logging, { date: new Date(), diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index 4431edcc4..6189f6ce2 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -23,11 +23,16 @@ import { setBaseUrlsForSources, } from '../../state/actions/resource-actions/all-views-simple-actions'; import { loadFromFile } from '../../state/actions/resource-actions/load-actions'; -import { openPopup } from '../../state/actions/view-actions/view-actions'; +import { + openPopup, + setLoading, + setLogMessage, +} from '../../state/actions/view-actions/view-actions'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { getBaseUrlsForSources } from '../../state/selectors/resource-selectors'; import { ExportFileRequestListener, + IsLoadingListener, LoggingListener, ShowImportDialogListener, useIpcRenderer, @@ -96,6 +101,13 @@ export const BackendCommunication: React.FC = () => { } } + useIpcRenderer( + AllowedFrontendChannels.FileLoading, + (_, { isLoading }) => { + dispatch(setLoading(isLoading)); + }, + [dispatch], + ); useIpcRenderer(AllowedFrontendChannels.FileLoaded, fileLoadedListener, [ dispatch, ]); @@ -106,8 +118,11 @@ export const BackendCommunication: React.FC = () => { ); useIpcRenderer( AllowedFrontendChannels.Logging, - (_, { date, level, message }) => - console[level](`${dayjs(date).format('HH:mm:ss.SSS')} ${message}`), + (_, log) => { + const { date, level, message } = log; + console[level](`${dayjs(date).format('HH:mm:ss.SSS')} ${message}`); + dispatch(setLogMessage(log)); + }, [dispatch], ); useIpcRenderer( diff --git a/src/Frontend/Components/ImportDialog/ImportDialog.tsx b/src/Frontend/Components/ImportDialog/ImportDialog.tsx index 33656d9b4..12f887859 100644 --- a/src/Frontend/Components/ImportDialog/ImportDialog.tsx +++ b/src/Frontend/Components/ImportDialog/ImportDialog.tsx @@ -4,15 +4,14 @@ // SPDX-License-Identifier: Apache-2.0 import MuiBox from '@mui/material/Box'; import MuiTypography from '@mui/material/Typography'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; -import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; import { FileFormatInfo, Log } from '../../../shared/shared-types'; import { text } from '../../../shared/text'; import { getDotOpossumFilePath } from '../../../shared/write-file'; import { closePopup } from '../../state/actions/view-actions/view-actions'; -import { useAppDispatch } from '../../state/hooks'; -import { LoggingListener, useIpcRenderer } from '../../util/use-ipc-renderer'; +import { useAppDispatch, useAppSelector } from '../../state/hooks'; +import { getLogMessage } from '../../state/selectors/view-selector'; import { FilePathInput } from '../FilePathInput/FilePathInput'; import { LogDisplay } from '../LogDisplay/LogDisplay'; import { NotificationPopup } from '../NotificationPopup/NotificationPopup'; @@ -27,22 +26,23 @@ export const ImportDialog: React.FC = ({ fileFormat }) => { const [inputFilePath, setInputFilePath] = useState(''); const [opossumFilePath, setOpossumFilePath] = useState(''); - const [currentLog, setCurrentLog] = useState(null); - const [isLoading, setIsLoading] = useState(false); - useIpcRenderer( - AllowedFrontendChannels.Logging, - (_, log) => setCurrentLog(log), - [], - ); + const newestLogMessage = useAppSelector(getLogMessage); + const [logToDisplay, setLogToDisplay] = useState(null); + + useEffect(() => { + if (isLoading) { + setLogToDisplay(newestLogMessage); + } + }, [isLoading, newestLogMessage]); function selectInputFilePath(): void { window.electronAPI.importFileSelectInput(fileFormat).then( (filePath) => { if (filePath) { setInputFilePath(filePath); - setCurrentLog(null); + setLogToDisplay(null); } }, () => {}, @@ -66,7 +66,7 @@ export const ImportDialog: React.FC = ({ fileFormat }) => { (filePath) => { if (filePath) { setOpossumFilePath(filePath); - setCurrentLog(null); + setLogToDisplay(null); } }, () => {}, @@ -125,7 +125,7 @@ export const ImportDialog: React.FC = ({ fileFormat }) => { } isOpen={true} customAction={ - currentLog ? ( + logToDisplay ? ( = ({ fileFormat }) => { }} > >([]); - const [loading, setLoading] = useState(false); + const loading = useAppSelector(isLoading); + const newestLogMessage = useAppSelector(getLogMessage); - useIpcRenderer( - AllowedFrontendChannels.Logging, - (_, log) => setLogs((prev) => [...prev, log]), - [], - ); - - useIpcRenderer( - AllowedFrontendChannels.FileLoading, - (_, { isLoading }) => { - setLoading(isLoading); + useEffect(() => { + if (loading) { + setLogs([]); + } + }, [loading]); - // reset component state - if (isLoading) { - setLogs([]); - } - }, - [], - ); + useEffect(() => { + if (newestLogMessage) { + setLogs((prev) => [...prev, newestLogMessage]); + } + }, [newestLogMessage]); return ( diff --git a/src/Frontend/state/actions/view-actions/types.ts b/src/Frontend/state/actions/view-actions/types.ts index 24cb6a57e..e0e278cb4 100644 --- a/src/Frontend/state/actions/view-actions/types.ts +++ b/src/Frontend/state/actions/view-actions/types.ts @@ -2,7 +2,11 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { ExportType, FileFormatInfo } from '../../../../shared/shared-types'; +import { + ExportType, + FileFormatInfo, + Log, +} from '../../../../shared/shared-types'; import { View } from '../../../enums/enums'; import { PopupInfo } from '../../../types/types'; @@ -14,6 +18,8 @@ export const ACTION_RESET_VIEW_STATE = 'ACTION_RESET_VIEW_STATE'; export const ACTION_SET_OPEN_FILE_REQUEST = 'ACTION_SET_OPEN_FILE_REQUEST'; export const ACTION_SET_IMPORT_FILE_REQUEST = 'ACTION_SET_IMPORT_FILE_REQUEST'; export const ACTION_SET_EXPORT_FILE_REQUEST = 'ACTION_SET_EXPORT_FILE_REQUEST'; +export const ACTION_SET_LOADING = 'ACTION_SET_LOADING'; +export const ACTION_SET_LOG_MESSAGE = 'ACTION_SET_LOG_MESSAGE'; export type ViewAction = | SetView @@ -23,7 +29,9 @@ export type ViewAction = | OpenPopupAction | SetOpenFileRequestAction | SetImportFileRequestAction - | SetExportFileRequestAction; + | SetExportFileRequestAction + | SetLoadingAction + | SetLogMessageAction; export interface ResetViewStateAction { type: typeof ACTION_RESET_VIEW_STATE; @@ -62,3 +70,13 @@ export interface SetExportFileRequestAction { type: typeof ACTION_SET_EXPORT_FILE_REQUEST; payload: ExportType | null; } + +export interface SetLoadingAction { + type: typeof ACTION_SET_LOADING; + payload: boolean; +} + +export interface SetLogMessageAction { + type: typeof ACTION_SET_LOG_MESSAGE; + payload: Log; +} diff --git a/src/Frontend/state/actions/view-actions/view-actions.ts b/src/Frontend/state/actions/view-actions/view-actions.ts index 927536532..d8d254803 100644 --- a/src/Frontend/state/actions/view-actions/view-actions.ts +++ b/src/Frontend/state/actions/view-actions/view-actions.ts @@ -2,7 +2,11 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { ExportType, FileFormatInfo } from '../../../../shared/shared-types'; +import { + ExportType, + FileFormatInfo, + Log, +} from '../../../../shared/shared-types'; import { PopupType, View } from '../../../enums/enums'; import { EMPTY_DISPLAY_PACKAGE_INFO } from '../../../shared-constants'; import { State } from '../../../types/types'; @@ -16,6 +20,8 @@ import { ACTION_RESET_VIEW_STATE, ACTION_SET_EXPORT_FILE_REQUEST, ACTION_SET_IMPORT_FILE_REQUEST, + ACTION_SET_LOADING, + ACTION_SET_LOG_MESSAGE, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, ACTION_SET_VIEW, @@ -24,6 +30,8 @@ import { ResetViewStateAction, SetExportFileRequestAction, SetImportFileRequestAction, + SetLoadingAction, + SetLogMessageAction, SetOpenFileRequestAction, SetTargetView, SetView, @@ -101,3 +109,11 @@ export function setExportFileRequest( ): SetExportFileRequestAction { return { type: ACTION_SET_EXPORT_FILE_REQUEST, payload: exportFileRequest }; } + +export function setLoading(loading: boolean): SetLoadingAction { + return { type: ACTION_SET_LOADING, payload: loading }; +} + +export function setLogMessage(message: Log): SetLogMessageAction { + return { type: ACTION_SET_LOG_MESSAGE, payload: message }; +} diff --git a/src/Frontend/state/reducers/view-reducer.ts b/src/Frontend/state/reducers/view-reducer.ts index 0ec1bff0c..a3dcfaf29 100644 --- a/src/Frontend/state/reducers/view-reducer.ts +++ b/src/Frontend/state/reducers/view-reducer.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { ExportType, FileFormatInfo } from '../../../shared/shared-types'; +import { ExportType, FileFormatInfo, Log } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo } from '../../types/types'; import { @@ -12,6 +12,8 @@ import { ACTION_RESET_VIEW_STATE, ACTION_SET_EXPORT_FILE_REQUEST, ACTION_SET_IMPORT_FILE_REQUEST, + ACTION_SET_LOADING, + ACTION_SET_LOG_MESSAGE, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, ACTION_SET_VIEW, @@ -25,6 +27,8 @@ export interface ViewState { openFileRequest: boolean; importFileRequest: FileFormatInfo | null; exportFileRequest: ExportType | null; + loading: boolean; + logMessage: Log | null; } export const initialViewState: ViewState = { @@ -34,6 +38,8 @@ export const initialViewState: ViewState = { openFileRequest: false, importFileRequest: null, exportFileRequest: null, + loading: false, + logMessage: null, }; export function viewState( @@ -82,6 +88,16 @@ export function viewState( ...state, exportFileRequest: action.payload, }; + case ACTION_SET_LOADING: + return { + ...state, + loading: action.payload, + }; + case ACTION_SET_LOG_MESSAGE: + return { + ...state, + logMessage: action.payload, + }; default: return state; } diff --git a/src/Frontend/state/selectors/view-selector.ts b/src/Frontend/state/selectors/view-selector.ts index 4222d52f6..05a643a61 100644 --- a/src/Frontend/state/selectors/view-selector.ts +++ b/src/Frontend/state/selectors/view-selector.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { ExportType, FileFormatInfo } from '../../../shared/shared-types'; +import { ExportType, FileFormatInfo, Log } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo, State } from '../../types/types'; @@ -46,3 +46,11 @@ export function getImportFileRequest(state: State): FileFormatInfo | null { export function getExportFileRequest(state: State): ExportType | null { return state.viewState.exportFileRequest; } + +export function isLoading(state: State): boolean { + return state.viewState.loading; +} + +export function getLogMessage(state: State): Log | null { + return state.viewState.logMessage; +} From 6004cce50c00de02ad29670dee0923878d1cfa1b Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 12:41:38 +0100 Subject: [PATCH 19/26] refactor: introduce a new hook to simplify using redux state for event propagation --- .../Components/ImportDialog/ImportDialog.tsx | 19 +++++++++++-------- .../Components/ProcessPopup/ProcessPopup.tsx | 17 ++++++++++------- .../resource-actions/export-actions.ts | 4 +++- src/Frontend/state/hooks.ts | 17 ++++++++++++++++- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/Frontend/Components/ImportDialog/ImportDialog.tsx b/src/Frontend/Components/ImportDialog/ImportDialog.tsx index 12f887859..c84c91e94 100644 --- a/src/Frontend/Components/ImportDialog/ImportDialog.tsx +++ b/src/Frontend/Components/ImportDialog/ImportDialog.tsx @@ -4,13 +4,13 @@ // SPDX-License-Identifier: Apache-2.0 import MuiBox from '@mui/material/Box'; import MuiTypography from '@mui/material/Typography'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { FileFormatInfo, Log } from '../../../shared/shared-types'; import { text } from '../../../shared/text'; import { getDotOpossumFilePath } from '../../../shared/write-file'; import { closePopup } from '../../state/actions/view-actions/view-actions'; -import { useAppDispatch, useAppSelector } from '../../state/hooks'; +import { useAppDispatch, useStateEffect } from '../../state/hooks'; import { getLogMessage } from '../../state/selectors/view-selector'; import { FilePathInput } from '../FilePathInput/FilePathInput'; import { LogDisplay } from '../LogDisplay/LogDisplay'; @@ -28,14 +28,17 @@ export const ImportDialog: React.FC = ({ fileFormat }) => { const [isLoading, setIsLoading] = useState(false); - const newestLogMessage = useAppSelector(getLogMessage); const [logToDisplay, setLogToDisplay] = useState(null); - useEffect(() => { - if (isLoading) { - setLogToDisplay(newestLogMessage); - } - }, [isLoading, newestLogMessage]); + useStateEffect( + getLogMessage, + (log) => { + if (isLoading) { + setLogToDisplay(log); + } + }, + [isLoading], + ); function selectInputFilePath(): void { window.electronAPI.importFileSelectInput(fileFormat).then( diff --git a/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx b/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx index a298086ee..539cbb9bd 100644 --- a/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx +++ b/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react'; import { Log } from '../../../shared/shared-types'; import { text } from '../../../shared/text'; -import { useAppSelector } from '../../state/hooks'; +import { useAppSelector, useStateEffect } from '../../state/hooks'; import { getLogMessage, isLoading } from '../../state/selectors/view-selector'; import { LogDisplay } from '../LogDisplay/LogDisplay'; import { DialogContent } from './ProcessPopup.style'; @@ -16,7 +16,6 @@ import { DialogContent } from './ProcessPopup.style'; export function ProcessPopup() { const [logs, setLogs] = useState>([]); const loading = useAppSelector(isLoading); - const newestLogMessage = useAppSelector(getLogMessage); useEffect(() => { if (loading) { @@ -24,11 +23,15 @@ export function ProcessPopup() { } }, [loading]); - useEffect(() => { - if (newestLogMessage) { - setLogs((prev) => [...prev, newestLogMessage]); - } - }, [newestLogMessage]); + useStateEffect( + getLogMessage, + (log) => { + if (log) { + setLogs((prev) => [...prev, log]); + } + }, + [], + ); return ( diff --git a/src/Frontend/state/actions/resource-actions/export-actions.ts b/src/Frontend/state/actions/resource-actions/export-actions.ts index 386eee09e..11e19a3ed 100644 --- a/src/Frontend/state/actions/resource-actions/export-actions.ts +++ b/src/Frontend/state/actions/resource-actions/export-actions.ts @@ -25,9 +25,11 @@ import { getResources, } from '../../selectors/resource-selectors'; import { AppThunkAction } from '../../types'; +import { setLoading } from '../view-actions/view-actions'; export function exportFile(exportType: ExportType): AppThunkAction { - return (_, getState) => { + return (dispatch, getState) => { + dispatch(setLoading(true)); switch (exportType) { case ExportType.SpdxDocumentJson: case ExportType.SpdxDocumentYaml: diff --git a/src/Frontend/state/hooks.ts b/src/Frontend/state/hooks.ts index e9e75c268..cbc435c3d 100644 --- a/src/Frontend/state/hooks.ts +++ b/src/Frontend/state/hooks.ts @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 import { memoize } from 'proxy-memoize'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { useDispatch, useSelector, useStore } from 'react-redux'; import { State } from '../types/types'; @@ -19,3 +19,18 @@ export const useAppSelector = ( useSelector(useCallback(memoize(fn), deps)); export const useAppStore = useStore; + +type WithParameter = F extends () => infer R ? (v: A) => R : never; + +export const useStateEffect = ( + selector: (state: State) => T, + effect: WithParameter, + effectDeps: Array, + selectorDeps: Array = [], +): void => { + const selectedState = useAppSelector(selector, selectorDeps); + useEffect(() => { + return effect(selectedState); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedState, effect, ...effectDeps]); +}; From ee22348e6039f38a4baa12ec42784b20ae4670ed Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 15:31:45 +0100 Subject: [PATCH 20/26] fix: cache effect callback in useStateEffect to avoid unnecessary rerenders --- src/Frontend/state/hooks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Frontend/state/hooks.ts b/src/Frontend/state/hooks.ts index cbc435c3d..d1972babc 100644 --- a/src/Frontend/state/hooks.ts +++ b/src/Frontend/state/hooks.ts @@ -29,8 +29,9 @@ export const useStateEffect = ( selectorDeps: Array = [], ): void => { const selectedState = useAppSelector(selector, selectorDeps); + // eslint-disable-next-line react-hooks/exhaustive-deps + const effectCallback = useCallback(effect, effectDeps); useEffect(() => { - return effect(selectedState); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedState, effect, ...effectDeps]); + return effectCallback(selectedState); + }, [selectedState, effectCallback]); }; From a3faa4d2c78706c01a5f3196deba8080fa9a41d0 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 15:33:06 +0100 Subject: [PATCH 21/26] fix: fix broken unit test for ProcessPopup --- .../__tests__/ProcessPopup.test.tsx | 71 +++++-------------- 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx index 75883a16e..70d69a012 100644 --- a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx +++ b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx @@ -2,40 +2,18 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { act, screen } from '@testing-library/react'; -import { IpcRendererEvent } from 'electron'; -import { noop } from 'lodash'; +import { screen } from '@testing-library/react'; -import { AllowedFrontendChannels } from '../../../../shared/ipc-channels'; -import { ElectronAPI, Log } from '../../../../shared/shared-types'; import { text } from '../../../../shared/text'; import { faker } from '../../../../testing/Faker'; +import { + setLoading, + setLogMessage, +} from '../../../state/actions/view-actions/view-actions'; import { renderComponent } from '../../../test-helpers/render'; import { ProcessPopup } from '../ProcessPopup'; -type Listener = (event: IpcRendererEvent, ...args: Array) => void; - -const electronAPI: { - events: Partial>; - on: (channel: AllowedFrontendChannels, listener: Listener) => () => void; - send: (channel: AllowedFrontendChannels, ...args: Array) => void; -} = { - events: {}, - on(channel: AllowedFrontendChannels, listener: Listener): () => void { - this.events[channel] = listener; - return noop; - }, - send(channel: AllowedFrontendChannels, ...args: Array): void { - this.events[channel]?.({} as IpcRendererEvent, ...args); - }, -}; - describe('ProcessPopup', () => { - beforeEach(() => { - electronAPI.events = {}; - global.window.electronAPI = electronAPI as unknown as ElectronAPI; - }); - it('renders no dialog when loading is false', () => { renderComponent(); @@ -43,14 +21,7 @@ describe('ProcessPopup', () => { }); it('renders dialog when loading is true', () => { - renderComponent(); - - act( - () => - void electronAPI.send(AllowedFrontendChannels.FileLoading, { - isLoading: true, - }), - ); + renderComponent(, { actions: [setLoading(true)] }); expect(screen.getByText(text.processPopup.title)).toBeInTheDocument(); }); @@ -58,28 +29,24 @@ describe('ProcessPopup', () => { it('clears previous log messages when loading begins another time', () => { const date = faker.date.recent(); const message = faker.lorem.sentence(); - renderComponent(); - act( - () => - void electronAPI.send(AllowedFrontendChannels.FileLoading, { - isLoading: true, - }), - ); - act( - () => - void electronAPI.send(AllowedFrontendChannels.Logging, { + const popup = ; + + const { store, rerender } = renderComponent(popup, { + actions: [ + setLoading(true), + setLogMessage({ date, message, level: 'info', - } satisfies Log), - ); - act( - () => - void electronAPI.send(AllowedFrontendChannels.FileLoading, { - isLoading: true, }), - ); + ], + }); + + store.dispatch(setLoading(false)); + rerender(popup); + store.dispatch(setLoading(true)); + rerender(popup); expect(screen.queryByText(message)).not.toBeInTheDocument(); }); From 68871d31dba57fdf6476b4ca3b87bfe2ed884239 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 15:49:59 +0100 Subject: [PATCH 22/26] feat: reintroduce log message at the start of export action --- .../BackendCommunication.tsx | 4 +-- .../__tests__/ProcessPopup.test.tsx | 4 +-- .../resource-actions/export-actions.ts | 29 +++++++++++++++++-- .../actions/view-actions/view-actions.ts | 12 ++++++-- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx index 6189f6ce2..c58fc93f5 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -26,7 +26,7 @@ import { loadFromFile } from '../../state/actions/resource-actions/load-actions' import { openPopup, setLoading, - setLogMessage, + writeLogMessage, } from '../../state/actions/view-actions/view-actions'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { getBaseUrlsForSources } from '../../state/selectors/resource-selectors'; @@ -121,7 +121,7 @@ export const BackendCommunication: React.FC = () => { (_, log) => { const { date, level, message } = log; console[level](`${dayjs(date).format('HH:mm:ss.SSS')} ${message}`); - dispatch(setLogMessage(log)); + dispatch(writeLogMessage(log)); }, [dispatch], ); diff --git a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx index 70d69a012..9e6cf6915 100644 --- a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx +++ b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx @@ -8,7 +8,7 @@ import { text } from '../../../../shared/text'; import { faker } from '../../../../testing/Faker'; import { setLoading, - setLogMessage, + writeLogMessage, } from '../../../state/actions/view-actions/view-actions'; import { renderComponent } from '../../../test-helpers/render'; import { ProcessPopup } from '../ProcessPopup'; @@ -35,7 +35,7 @@ describe('ProcessPopup', () => { const { store, rerender } = renderComponent(popup, { actions: [ setLoading(true), - setLogMessage({ + writeLogMessage({ date, message, level: 'info', diff --git a/src/Frontend/state/actions/resource-actions/export-actions.ts b/src/Frontend/state/actions/resource-actions/export-actions.ts index 11e19a3ed..7f571812b 100644 --- a/src/Frontend/state/actions/resource-actions/export-actions.ts +++ b/src/Frontend/state/actions/resource-actions/export-actions.ts @@ -25,20 +25,45 @@ import { getResources, } from '../../selectors/resource-selectors'; import { AppThunkAction } from '../../types'; -import { setLoading } from '../view-actions/view-actions'; +import { setLoading, writeInfoLogMessage } from '../view-actions/view-actions'; export function exportFile(exportType: ExportType): AppThunkAction { return (dispatch, getState) => { dispatch(setLoading(true)); + switch (exportType) { case ExportType.SpdxDocumentJson: + dispatch(writeInfoLogMessage('Preparing data for SPDX (json) export')); + return getSpdxDocumentExportListener( + getState(), + ExportType.SpdxDocumentJson, + ); + case ExportType.SpdxDocumentYaml: - return getSpdxDocumentExportListener(getState(), exportType); + dispatch(writeInfoLogMessage('Preparing data for SPDX (yaml) export')); + return getSpdxDocumentExportListener( + getState(), + ExportType.SpdxDocumentYaml, + ); + case ExportType.FollowUp: + dispatch(writeInfoLogMessage('Preparing data for follow-up export')); return getFollowUpExportListener(getState()); + case ExportType.CompactBom: + dispatch( + writeInfoLogMessage( + 'Preparing data for compact component list export', + ), + ); return getCompactBomExportListener(getState()); + case ExportType.DetailedBom: + dispatch( + writeInfoLogMessage( + 'Preparing data for detailed component list export', + ), + ); return getDetailedBomExportListener(getState()); } }; diff --git a/src/Frontend/state/actions/view-actions/view-actions.ts b/src/Frontend/state/actions/view-actions/view-actions.ts index d8d254803..60e3cd12f 100644 --- a/src/Frontend/state/actions/view-actions/view-actions.ts +++ b/src/Frontend/state/actions/view-actions/view-actions.ts @@ -114,6 +114,14 @@ export function setLoading(loading: boolean): SetLoadingAction { return { type: ACTION_SET_LOADING, payload: loading }; } -export function setLogMessage(message: Log): SetLogMessageAction { - return { type: ACTION_SET_LOG_MESSAGE, payload: message }; +export function writeLogMessage(log: Log): SetLogMessageAction { + return { type: ACTION_SET_LOG_MESSAGE, payload: log }; +} + +export function writeInfoLogMessage(message: string): SetLogMessageAction { + return writeLogMessage({ + date: new Date(), + message, + level: 'info', + }); } From 3422c7018691c96508ab8b27826325b62e00c78d Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 16:42:31 +0100 Subject: [PATCH 23/26] refactor: rename functions in export-actions --- .../resource-actions/export-actions.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Frontend/state/actions/resource-actions/export-actions.ts b/src/Frontend/state/actions/resource-actions/export-actions.ts index 7f571812b..f51a8bd4e 100644 --- a/src/Frontend/state/actions/resource-actions/export-actions.ts +++ b/src/Frontend/state/actions/resource-actions/export-actions.ts @@ -34,21 +34,18 @@ export function exportFile(exportType: ExportType): AppThunkAction { switch (exportType) { case ExportType.SpdxDocumentJson: dispatch(writeInfoLogMessage('Preparing data for SPDX (json) export')); - return getSpdxDocumentExportListener( - getState(), - ExportType.SpdxDocumentJson, - ); + exportSpdxDocument(getState(), ExportType.SpdxDocumentJson); + break; case ExportType.SpdxDocumentYaml: dispatch(writeInfoLogMessage('Preparing data for SPDX (yaml) export')); - return getSpdxDocumentExportListener( - getState(), - ExportType.SpdxDocumentYaml, - ); + exportSpdxDocument(getState(), ExportType.SpdxDocumentYaml); + break; case ExportType.FollowUp: dispatch(writeInfoLogMessage('Preparing data for follow-up export')); - return getFollowUpExportListener(getState()); + exportFollowUp(getState()); + break; case ExportType.CompactBom: dispatch( @@ -56,7 +53,8 @@ export function exportFile(exportType: ExportType): AppThunkAction { 'Preparing data for compact component list export', ), ); - return getCompactBomExportListener(getState()); + exportCompactBom(getState()); + break; case ExportType.DetailedBom: dispatch( @@ -64,12 +62,13 @@ export function exportFile(exportType: ExportType): AppThunkAction { 'Preparing data for detailed component list export', ), ); - return getDetailedBomExportListener(getState()); + exportDetailedBom(getState()); + break; } }; } -function getFollowUpExportListener(state: State): void { +function exportFollowUp(state: State): void { const followUpAttributions = pick( getManualData(state).attributions, Object.keys(getManualData(state).attributions).filter( @@ -100,7 +99,7 @@ function getFollowUpExportListener(state: State): void { }); } -function getSpdxDocumentExportListener( +function exportSpdxDocument( state: State, exportType: ExportType.SpdxDocumentYaml | ExportType.SpdxDocumentJson, ): void { @@ -133,7 +132,7 @@ function getSpdxDocumentExportListener( window.electronAPI.exportFile(args); } -function getDetailedBomExportListener(state: State): void { +function exportDetailedBom(state: State): void { const bomAttributions = getBomAttributions( getManualData(state).attributions, ExportType.DetailedBom, @@ -156,7 +155,7 @@ function getDetailedBomExportListener(state: State): void { }); } -function getCompactBomExportListener(state: State): void { +function exportCompactBom(state: State): void { window.electronAPI.exportFile({ type: ExportType.CompactBom, bomAttributions: getBomAttributions( From 8d95f1d5c4a2ea7d2148611e8fa37deb494de9de Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Fri, 14 Feb 2025 17:25:17 +0100 Subject: [PATCH 24/26] test: add missing test and move all NotSavedPopup tests to updating-attributions.test.ts --- src/e2e-tests/__tests__/import-dialog.test.ts | 23 ++++---------- .../__tests__/updating-attributions.test.ts | 31 ++++++++++++++++++- src/e2e-tests/page-objects/MenuBar.ts | 12 +++++-- src/e2e-tests/utils/fixtures.ts | 8 ++--- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/e2e-tests/__tests__/import-dialog.test.ts b/src/e2e-tests/__tests__/import-dialog.test.ts index 83e6be5c5..64d63f0ce 100644 --- a/src/e2e-tests/__tests__/import-dialog.test.ts +++ b/src/e2e-tests/__tests__/import-dialog.test.ts @@ -22,14 +22,14 @@ test.use({ outputData: faker.opossum.outputData({}), provideImportFiles: true, }, - isImportFileTest: true, + openFromCLI: false, }); test('opens, displays and closes import dialog', async ({ menuBar, importDialog, }) => { - await menuBar.openImportLegacyOpossumFile(); + await menuBar.importLegacyOpossumFile(); await importDialog.assert.titleIsVisible(); await importDialog.cancelButton.click(); @@ -37,13 +37,11 @@ test('opens, displays and closes import dialog', async ({ await importDialog.assert.titleIsHidden(); }); -test('imports legacy opossum file and checks for unsaved changes', async ({ +test('imports legacy opossum file', async ({ menuBar, importDialog, resourcesTree, window, - attributionDetails, - notSavedPopup, }) => { await stubDialog(window.app, 'showOpenDialogSync', [ importDialog.legacyFilePath, @@ -54,7 +52,7 @@ test('imports legacy opossum file and checks for unsaved changes', async ({ getDotOpossumFilePath(importDialog.legacyFilePath, ['json', 'json.gz']), ); - await menuBar.openImportLegacyOpossumFile(); + await menuBar.importLegacyOpossumFile(); await importDialog.assert.titleIsVisible(); await importDialog.inputFileSelection.click(); @@ -63,15 +61,6 @@ test('imports legacy opossum file and checks for unsaved changes', async ({ await importDialog.assert.titleIsHidden(); await resourcesTree.assert.resourceIsVisible(resourceName); - - const comment = faker.lorem.sentences(); - await resourcesTree.goto(resourceName); - await attributionDetails.attributionForm.comment.fill(comment); - - await menuBar.openImportLegacyOpossumFile(); - await notSavedPopup.assert.isVisible(); - await notSavedPopup.discardButton.click(); - await importDialog.assert.titleIsVisible(); }); test('imports scancode file', async ({ @@ -89,7 +78,7 @@ test('imports scancode file', async ({ getDotOpossumFilePath(importDialog.scancodeFilePath, ['json']), ); - await menuBar.openImportScanCodeFile(); + await menuBar.importScanCodeFile(); await importDialog.assert.titleIsVisible(); await importDialog.inputFileSelection.click(); @@ -104,7 +93,7 @@ test('shows error when no file path is set', async ({ menuBar, importDialog, }) => { - await menuBar.openImportLegacyOpossumFile(); + await menuBar.importLegacyOpossumFile(); await importDialog.assert.titleIsVisible(); await importDialog.importButton.click(); diff --git a/src/e2e-tests/__tests__/updating-attributions.test.ts b/src/e2e-tests/__tests__/updating-attributions.test.ts index d4dd7c69c..cae3f7e29 100644 --- a/src/e2e-tests/__tests__/updating-attributions.test.ts +++ b/src/e2e-tests/__tests__/updating-attributions.test.ts @@ -66,6 +66,7 @@ test('warns user of unsaved changes if user attempts to open new file before sav notSavedPopup, resourcesTree, topBar, + menuBar, }) => { const comment = faker.lorem.sentences(); await resourcesTree.goto(resourceName1); @@ -77,7 +78,7 @@ test('warns user of unsaved changes if user attempts to open new file before sav await notSavedPopup.cancelButton.click(); await attributionDetails.attributionForm.assert.commentIs(comment); - await topBar.openFileButton.click(); + await menuBar.openFile(); await notSavedPopup.assert.isVisible(); }); @@ -109,6 +110,34 @@ test('warns user of unsaved changes if user attempts to navigate away before sav await topBar.assert.reportViewIsActive(); }); +test('warns user of unsaved changes if user attempts to import new file before saving', async ({ + attributionDetails, + notSavedPopup, + resourcesTree, + menuBar, +}) => { + const comment = faker.lorem.sentences(); + await resourcesTree.goto(resourceName1); + await attributionDetails.attributionForm.comment.fill(comment); + + await menuBar.importLegacyOpossumFile(); + await notSavedPopup.assert.isVisible(); +}); + +test('warns user of unsaved changes if user attempts to export data before saving', async ({ + attributionDetails, + notSavedPopup, + resourcesTree, + menuBar, +}) => { + const comment = faker.lorem.sentences(); + await resourcesTree.goto(resourceName1); + await attributionDetails.attributionForm.comment.fill(comment); + + await menuBar.exportFollowUp(); + await notSavedPopup.assert.isVisible(); +}); + test('allows user to update an attribution on the selected resource only', async ({ attributionDetails, resourcesTree, diff --git a/src/e2e-tests/page-objects/MenuBar.ts b/src/e2e-tests/page-objects/MenuBar.ts index 0ee243b65..1e42d20c3 100644 --- a/src/e2e-tests/page-objects/MenuBar.ts +++ b/src/e2e-tests/page-objects/MenuBar.ts @@ -22,11 +22,15 @@ export class MenuBar { await clickMenuItem(this.window.app, 'label', 'Project Metadata'); } + async openFile(): Promise { + await clickMenuItem(this.window.app, 'label', 'Open File'); + } + async openProjectStatistics(): Promise { await clickMenuItem(this.window.app, 'label', 'Project Statistics'); } - async openImportLegacyOpossumFile(): Promise { + async importLegacyOpossumFile(): Promise { await clickMenuItem( this.window.app, 'label', @@ -34,10 +38,14 @@ export class MenuBar { ); } - async openImportScanCodeFile(): Promise { + async importScanCodeFile(): Promise { await clickMenuItem(this.window.app, 'label', 'ScanCode File (.json)'); } + async exportFollowUp(): Promise { + await clickMenuItem(this.window.app, 'label', 'Follow-Up'); + } + async toggleQaMode(): Promise { await clickMenuItem(this.window.app, 'label', 'QA Mode'); } diff --git a/src/e2e-tests/utils/fixtures.ts b/src/e2e-tests/utils/fixtures.ts index fb6c1a6c5..f28ed5bea 100644 --- a/src/e2e-tests/utils/fixtures.ts +++ b/src/e2e-tests/utils/fixtures.ts @@ -67,7 +67,7 @@ export const test = base.extend<{ linkedResourcesTree: LinkedResourcesTree; menuBar: MenuBar; notSavedPopup: NotSavedPopup; - isImportFileTest: boolean; + openFromCLI: boolean; pathBar: PathBar; projectMetadataPopup: ProjectMetadataPopup; projectStatisticsPopup: ProjectStatisticsPopup; @@ -77,8 +77,8 @@ export const test = base.extend<{ topBar: TopBar; }>({ data: undefined, - isImportFileTest: false, - window: async ({ data, isImportFileTest }, use, info) => { + openFromCLI: true, + window: async ({ data, openFromCLI }, use, info) => { const filePath = data && (await createTestFile({ data, info })); const [executablePath, main] = getLaunchProps(); @@ -90,7 +90,7 @@ export const test = base.extend<{ const app = await electron.launch({ args: [ main, - ...(!filePath || isImportFileTest ? args : args.concat([filePath])), + ...(!filePath || !openFromCLI ? args : args.concat([filePath])), ], executablePath, }); From a3126151bfb512da3697c82b9a51d54fd0ed1254 Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Mon, 17 Feb 2025 10:37:00 +0100 Subject: [PATCH 25/26] feat: add minimum width and height for electron window --- src/ElectronBackend/main/createWindow.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ElectronBackend/main/createWindow.ts b/src/ElectronBackend/main/createWindow.ts index 16eee841b..e39000bfd 100644 --- a/src/ElectronBackend/main/createWindow.ts +++ b/src/ElectronBackend/main/createWindow.ts @@ -13,6 +13,8 @@ export async function createWindow(): Promise { const mainWindow = new BrowserWindow({ width: 1920, height: 1080, + minWidth: 500, + minHeight: 300, webPreferences: { contextIsolation: true, nodeIntegration: false, From bb47c74a4ee330a54579aba1c28d5312ec7398fe Mon Sep 17 00:00:00 2001 From: Philipp Martens Date: Mon, 17 Feb 2025 10:44:42 +0100 Subject: [PATCH 26/26] refactor: change ProcessPopup test to use act instead of rerender --- .../ProcessPopup/__tests__/ProcessPopup.test.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx index 9e6cf6915..f2eaccea2 100644 --- a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx +++ b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import { text } from '../../../../shared/text'; import { faker } from '../../../../testing/Faker'; @@ -32,7 +32,7 @@ describe('ProcessPopup', () => { const popup = ; - const { store, rerender } = renderComponent(popup, { + const { store } = renderComponent(popup, { actions: [ setLoading(true), writeLogMessage({ @@ -43,10 +43,8 @@ describe('ProcessPopup', () => { ], }); - store.dispatch(setLoading(false)); - rerender(popup); - store.dispatch(setLoading(true)); - rerender(popup); + act(() => void store.dispatch(setLoading(false))); + act(() => void store.dispatch(setLoading(true))); expect(screen.queryByText(message)).not.toBeInTheDocument(); });