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, 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/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/ElectronBackend/main/menu.ts b/src/ElectronBackend/main/menu.ts index a78c04bc2..e9fb1cad3 100644 --- a/src/ElectronBackend/main/menu.ts +++ b/src/ElectronBackend/main/menu.ts @@ -17,13 +17,7 @@ import { getIconBasedOnTheme, makeFirstIconVisibleAndSecondHidden, } from './iconHelpers'; -import { - getImportFileListener, - getOpenFileListener, - getSelectBaseURLListener, - setLoadingState, -} from './listeners'; -import logger from './logger'; +import { getImportFileListener, getSelectBaseURLListener } from './listeners'; import { getPathOfChromiumNoticeDocument, getPathOfNoticeDocument, @@ -113,7 +107,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( @@ -155,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, @@ -172,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, @@ -189,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, @@ -208,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, @@ -225,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/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..c58fc93f5 100644 --- a/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx +++ b/src/Frontend/Components/BackendCommunication/BackendCommunication.tsx @@ -5,47 +5,40 @@ // 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 { + exportFileOrOpenUnsavedPopup, + openFileOrOpenUnsavedPopup, + showImportDialogOrOpenUnsavedPopup, +} from '../../state/actions/popup-actions/popup-actions'; import { resetResourceState, 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 { useAppDispatch, useAppSelector } from '../../state/hooks'; import { - getAttributionBreakpoints, - getBaseUrlsForSources, - getFilesWithChildren, - getFrequentLicensesTexts, - getManualData, - getResources, -} from '../../state/selectors/resource-selectors'; + openPopup, + setLoading, + writeLogMessage, +} from '../../state/actions/view-actions/view-actions'; +import { useAppDispatch, useAppSelector } from '../../state/hooks'; +import { getBaseUrlsForSources } from '../../state/selectors/resource-selectors'; import { - getAttributionsWithAllChildResourcesWithoutFolders, - getAttributionsWithResources, - removeSlashesFromFilesWithChildren, -} from '../../util/get-attributions-with-resources'; -import { LoggingListener, useIpcRenderer } from '../../util/use-ipc-renderer'; + ExportFileRequestListener, + IsLoadingListener, + 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(); @@ -58,118 +51,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, @@ -220,6 +101,13 @@ export const BackendCommunication: React.FC = () => { } } + useIpcRenderer( + AllowedFrontendChannels.FileLoading, + (_, { isLoading }) => { + dispatch(setLoading(isLoading)); + }, + [dispatch], + ); useIpcRenderer(AllowedFrontendChannels.FileLoaded, fileLoadedListener, [ dispatch, ]); @@ -230,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(writeLogMessage(log)); + }, [dispatch], ); useIpcRenderer( @@ -249,39 +140,26 @@ export const BackendCommunication: React.FC = () => { setBaseURLForRootListener, [dispatch, baseUrlsForSources], ); - useIpcRenderer( + useIpcRenderer( AllowedFrontendChannels.ExportFileRequest, - getExportFileRequestListener, - [ - manualData, - attributionBreakpoints, - frequentLicenseTexts, - filesWithChildren, - ], + (_, exportType) => dispatch(exportFileOrOpenUnsavedPopup(exportType)), + [dispatch], ); useIpcRenderer( AllowedFrontendChannels.ShowUpdateAppPopup, showUpdateAppPopupListener, [dispatch], ); + useIpcRenderer( + AllowedFrontendChannels.OpenFileWithUnsavedCheck, + () => dispatch(openFileOrOpenUnsavedPopup()), + [dispatch], + ); + useIpcRenderer( + AllowedFrontendChannels.ImportFileShowDialog, + (_, fileFormat) => dispatch(showImportDialogOrOpenUnsavedPopup(fileFormat)), + [dispatch], + ); 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/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/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..c84c91e94 100644 --- a/src/Frontend/Components/ImportDialog/ImportDialog.tsx +++ b/src/Frontend/Components/ImportDialog/ImportDialog.tsx @@ -6,35 +6,38 @@ import MuiBox from '@mui/material/Box'; import MuiTypography from '@mui/material/Typography'; import { 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 { LoggingListener, useIpcRenderer } from '../../util/use-ipc-renderer'; +import { closePopup } from '../../state/actions/view-actions/view-actions'; +import { useAppDispatch, useStateEffect } 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'; 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(''); - const [currentLog, setCurrentLog] = useState(null); - const [isLoading, setIsLoading] = useState(false); - useIpcRenderer( - AllowedFrontendChannels.Logging, - (_, log) => setCurrentLog(log), - [], + const [logToDisplay, setLogToDisplay] = useState(null); + + useStateEffect( + getLogMessage, + (log) => { + if (isLoading) { + setLogToDisplay(log); + } + }, + [isLoading], ); function selectInputFilePath(): void { @@ -42,7 +45,7 @@ export const ImportDialog: React.FC = ({ (filePath) => { if (filePath) { setInputFilePath(filePath); - setCurrentLog(null); + setLogToDisplay(null); } }, () => {}, @@ -66,7 +69,7 @@ export const ImportDialog: React.FC = ({ (filePath) => { if (filePath) { setOpossumFilePath(filePath); - setCurrentLog(null); + setLogToDisplay(null); } }, () => {}, @@ -74,7 +77,7 @@ export const ImportDialog: React.FC = ({ } function onCancel(): void { - closeDialog(); + dispatch(closePopup()); } async function onConfirm(): Promise { @@ -87,7 +90,7 @@ export const ImportDialog: React.FC = ({ ); if (success) { - closeDialog(); + dispatch(closePopup()); } setIsLoading(false); @@ -125,17 +128,17 @@ export const ImportDialog: React.FC = ({ } isOpen={true} customAction={ - currentLog ? ( + logToDisplay ? ( -// -// 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/Components/ProcessPopup/ProcessPopup.tsx b/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx index c067fd8b0..539cbb9bd 100644 --- a/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx +++ b/src/Frontend/Components/ProcessPopup/ProcessPopup.tsx @@ -4,37 +4,30 @@ // SPDX-License-Identifier: Apache-2.0 import MuiDialog from '@mui/material/Dialog'; import MuiDialogTitle from '@mui/material/DialogTitle'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; -import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; import { Log } from '../../../shared/shared-types'; import { text } from '../../../shared/text'; -import { - IsLoadingListener, - LoggingListener, - useIpcRenderer, -} from '../../util/use-ipc-renderer'; +import { useAppSelector, useStateEffect } from '../../state/hooks'; +import { getLogMessage, isLoading } from '../../state/selectors/view-selector'; import { LogDisplay } from '../LogDisplay/LogDisplay'; import { DialogContent } from './ProcessPopup.style'; export function ProcessPopup() { const [logs, setLogs] = useState>([]); - const [loading, setLoading] = useState(false); + const loading = useAppSelector(isLoading); - 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([]); + useStateEffect( + getLogMessage, + (log) => { + if (log) { + setLogs((prev) => [...prev, log]); } }, [], diff --git a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx index 75883a16e..f2eaccea2 100644 --- a/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx +++ b/src/Frontend/Components/ProcessPopup/__tests__/ProcessPopup.test.tsx @@ -3,39 +3,17 @@ // // SPDX-License-Identifier: Apache-2.0 import { act, screen } from '@testing-library/react'; -import { IpcRendererEvent } from 'electron'; -import { noop } from 'lodash'; -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, + writeLogMessage, +} 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,22 @@ 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 } = renderComponent(popup, { + actions: [ + setLoading(true), + writeLogMessage({ date, message, level: 'info', - } satisfies Log), - ); - act( - () => - void electronAPI.send(AllowedFrontendChannels.FileLoading, { - isLoading: true, }), - ); + ], + }); + + act(() => void store.dispatch(setLoading(false))); + act(() => void store.dispatch(setLoading(true))); expect(screen.queryByText(message)).not.toBeInTheDocument(); }); diff --git a/src/Frontend/Components/TopBar/TopBar.tsx b/src/Frontend/Components/TopBar/TopBar.tsx index 79c3c097a..6cb2ddace 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'; + openFileOrOpenUnsavedPopup, + 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(openFileOrOpenUnsavedPopup()); } return ( 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..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 @@ -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,20 +41,25 @@ 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 { changeSelectedAttributionOrOpenUnsavedPopup, closePopupAndUnsetTargets, navigateToSelectedPathOrOpenUnsavedPopup, - navigateToTargetResourceOrAttributionOrOpenFileDialog, + proceedFromUnsavedPopup, setSelectedResourceIdOrOpenUnsavedPopup, setViewOrOpenUnsavedPopup, } from '../popup-actions'; @@ -303,66 +313,121 @@ 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( - navigateToTargetResourceOrAttributionOrOpenFileDialog(), - ); - return testStore.getState(); - } +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('closes popup', () => { - const state = prepareTestState(); - expect(getOpenPopup(state)).toBeFalsy(); - }); + it('closes popup', () => { + const state = prepareTestState(); + expect(getOpenPopup(state)).toBeFalsy(); + }); - it('sets the view', () => { - const state = prepareTestState(); - expect(getSelectedView(state)).toBe(View.Audit); - }); + it('sets the view', () => { + const state = prepareTestState(); + expect(getSelectedView(state)).toBe(View.Audit); + }); - it('sets targetSelectedResourceOrAttribution', () => { - const state = prepareTestState(); - expect(getSelectedResourceId(state)).toBe('newSelectedResource'); - }); + it('sets targetSelectedResourceOrAttribution', () => { + const state = prepareTestState(); + expect(getSelectedResourceId(state)).toBe('newSelectedResource'); + }); - it('sets temporaryDisplayPackageInfo', () => { - const state = prepareTestState(); - expect(getTemporaryDisplayPackageInfo(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(); + const fileFormat = { + fileType: FileType.LEGACY_OPOSSUM, + extensions: [], + name: '', + }; + testStore.dispatch(setImportFileRequest(fileFormat)); + testStore.dispatch(openPopup(PopupType.NotSavedPopup)); + testStore.dispatch(proceedFromUnsavedPopup()); - it('does not save temporaryDisplayPackageInfo', () => { - const state = prepareTestState(); - expect(getManualAttributions(state)).toMatchObject({}); + expect(getOpenPopup(testStore.getState())).toStrictEqual({ + popup: PopupType.ImportDialog, + attributionId: undefined, + fileFormat, }); + 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(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(); expect(getOpenPopup(testStore.getState())).toBeNull(); }); }); diff --git a/src/Frontend/state/actions/popup-actions/popup-actions.ts b/src/Frontend/state/actions/popup-actions/popup-actions.ts index d26dd7560..da7b7a455 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 { 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,8 @@ import { getSelectedResourceId, } from '../../selectors/resource-selectors'; import { + getExportFileRequest, + getImportFileRequest, getOpenFileRequest, getTargetView, } from '../../selectors/view-selector'; @@ -23,6 +29,7 @@ import { setTargetSelectedAttributionId, setTargetSelectedResourceId, } from '../resource-actions/audit-view-simple-actions'; +import { exportFile } from '../resource-actions/export-actions'; import { openResourceInResourceBrowser, setSelectedResourceOrAttributionIdToTargetValue, @@ -31,82 +38,135 @@ import { closePopup, navigateToView, openPopup, + setExportFileRequest, + setImportFileRequest, setOpenFileRequest, setTargetView, } from '../view-actions/view-actions'; -export function navigateToSelectedPathOrOpenUnsavedPopup( - resourcePath: string, -): AppThunkAction { +function withUnsavedCheck({ + executeImmediately, + requestContinuation, +}: { + 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({ + executeImmediately: (dispatch) => + dispatch(openResourceInResourceBrowser(resourcePath)), + requestContinuation: (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({ + executeImmediately: (dispatch) => { dispatch(setSelectedAttributionId(packageInfo?.id ?? '')); dispatch( setTemporaryDisplayPackageInfo( packageInfo || EMPTY_DISPLAY_PACKAGE_INFO, ), ); - } - }; + }, + requestContinuation: (dispatch) => + dispatch(setTargetSelectedAttributionId(packageInfo?.id || '')), + }); } export function setViewOrOpenUnsavedPopup(selectedView: View): AppThunkAction { - return (dispatch, getState) => { - if (getIsPackageInfoModified(getState())) { + return withUnsavedCheck({ + executeImmediately: (dispatch) => dispatch(navigateToView(selectedView)), + requestContinuation: (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({ + executeImmediately: (dispatch) => + dispatch(setSelectedResourceId(resourceId)), + requestContinuation: (dispatch) => + dispatch(setTargetSelectedResourceId(resourceId)), + }); +} + +export function showImportDialogOrOpenUnsavedPopup( + fileFormat: FileFormatInfo, +): AppThunkAction { + return withUnsavedCheck({ + executeImmediately: (dispatch) => + dispatch(openPopup(PopupType.ImportDialog, undefined, fileFormat)), + requestContinuation: (dispatch) => + dispatch(setImportFileRequest(fileFormat)), + }); +} + +export function openFileOrOpenUnsavedPopup(): AppThunkAction { + return withUnsavedCheck({ + executeImmediately: () => void window.electronAPI.openFile(), + requestContinuation: (dispatch) => dispatch(setOpenFileRequest(true)), + }); } -export function navigateToTargetResourceOrAttributionOrOpenFileDialog(): AppThunkAction { +export function exportFileOrOpenUnsavedPopup( + exportType: ExportType, +): AppThunkAction { + return withUnsavedCheck({ + executeImmediately: (dispatch) => dispatch(exportFile(exportType)), + requestContinuation: (dispatch) => + dispatch(setExportFileRequest(exportType)), + }); +} + +export function proceedFromUnsavedPopup(): AppThunkAction { return (dispatch, getState) => { const targetView = getTargetView(getState()); const openFileRequest = getOpenFileRequest(getState()); + const importFileRequest = getImportFileRequest(getState()); + const exportFileRequest = getExportFileRequest(getState()); dispatch(closePopup()); + if (openFileRequest) { void window.electronAPI.openFile(); dispatch(setOpenFileRequest(false)); - return; + } + + if (importFileRequest) { + dispatch(openPopup(PopupType.ImportDialog, undefined, importFileRequest)); + dispatch(setImportFileRequest(null)); + } + + if (exportFileRequest) { + dispatch(exportFile(exportFileRequest)); + dispatch(setExportFileRequest(null)); } dispatch(setSelectedResourceOrAttributionIdToTargetValue()); if (targetView) { dispatch(navigateToView(targetView)); } + dispatch( setTemporaryDisplayPackageInfo( getPackageInfoOfSelectedAttribution(getState()) || @@ -119,9 +179,11 @@ export function navigateToTargetResourceOrAttributionOrOpenFileDialog(): AppThun 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)); + dispatch(setExportFileRequest(null)); }; } 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..f51a8bd4e --- /dev/null +++ b/src/Frontend/state/actions/resource-actions/export-actions.ts @@ -0,0 +1,166 @@ +// 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 { + getAttributionsWithAllChildResourcesWithoutFolders, + getAttributionsWithResources, + getBomAttributions, + removeSlashesFromFilesWithChildren, +} from '../../../util/attribution-utils'; +import { + getAttributionBreakpoints, + getFilesWithChildren, + getFrequentLicensesTexts, + getManualData, + getResources, +} from '../../selectors/resource-selectors'; +import { AppThunkAction } from '../../types'; +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')); + exportSpdxDocument(getState(), ExportType.SpdxDocumentJson); + break; + + case ExportType.SpdxDocumentYaml: + dispatch(writeInfoLogMessage('Preparing data for SPDX (yaml) export')); + exportSpdxDocument(getState(), ExportType.SpdxDocumentYaml); + break; + + case ExportType.FollowUp: + dispatch(writeInfoLogMessage('Preparing data for follow-up export')); + exportFollowUp(getState()); + break; + + case ExportType.CompactBom: + dispatch( + writeInfoLogMessage( + 'Preparing data for compact component list export', + ), + ); + exportCompactBom(getState()); + break; + + case ExportType.DetailedBom: + dispatch( + writeInfoLogMessage( + 'Preparing data for detailed component list export', + ), + ); + exportDetailedBom(getState()); + break; + } + }; +} + +function exportFollowUp(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 exportSpdxDocument( + 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 exportDetailedBom(state: State): void { + const bomAttributions = getBomAttributions( + getManualData(state).attributions, + ExportType.DetailedBom, + ); + + const bomAttributionsWithResources = getAttributionsWithResources( + bomAttributions, + getManualData(state).attributionsToResources, + ); + + const bomAttributionsWithFormattedResources = + removeSlashesFromFilesWithChildren( + bomAttributionsWithResources, + getFilesWithChildren(state), + ); + + window.electronAPI.exportFile({ + type: ExportType.DetailedBom, + bomAttributionsWithResources: bomAttributionsWithFormattedResources, + }); +} + +function exportCompactBom(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 ba43ad6e9..e0e278cb4 100644 --- a/src/Frontend/state/actions/view-actions/types.ts +++ b/src/Frontend/state/actions/view-actions/types.ts @@ -2,6 +2,11 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 +import { + ExportType, + FileFormatInfo, + Log, +} from '../../../../shared/shared-types'; import { View } from '../../../enums/enums'; import { PopupInfo } from '../../../types/types'; @@ -11,6 +16,10 @@ 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 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 @@ -18,7 +27,11 @@ export type ViewAction = | ClosePopupAction | ResetViewStateAction | OpenPopupAction - | SetOpenFileRequestAction; + | SetOpenFileRequestAction + | SetImportFileRequestAction + | SetExportFileRequestAction + | SetLoadingAction + | SetLogMessageAction; export interface ResetViewStateAction { type: typeof ACTION_RESET_VIEW_STATE; @@ -47,3 +60,23 @@ 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; +} + +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 37308f429..60e3cd12f 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 { 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'; @@ -14,12 +18,20 @@ import { ACTION_CLOSE_POPUP, ACTION_OPEN_POPUP, 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, ClosePopupAction, OpenPopupAction, ResetViewStateAction, + SetExportFileRequestAction, + SetImportFileRequestAction, + SetLoadingAction, + SetLogMessageAction, SetOpenFileRequestAction, SetTargetView, SetView, @@ -85,3 +97,31 @@ 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 }; +} + +export function setExportFileRequest( + exportFileRequest: ExportType | null, +): 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 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', + }); +} diff --git a/src/Frontend/state/hooks.ts b/src/Frontend/state/hooks.ts index e9e75c268..d1972babc 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,19 @@ 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); + // eslint-disable-next-line react-hooks/exhaustive-deps + const effectCallback = useCallback(effect, effectDeps); + useEffect(() => { + return effectCallback(selectedState); + }, [selectedState, effectCallback]); +}; diff --git a/src/Frontend/state/reducers/view-reducer.ts b/src/Frontend/state/reducers/view-reducer.ts index 369c5fea2..a3dcfaf29 100644 --- a/src/Frontend/state/reducers/view-reducer.ts +++ b/src/Frontend/state/reducers/view-reducer.ts @@ -3,12 +3,17 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 +import { ExportType, FileFormatInfo, Log } 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_LOADING, + ACTION_SET_LOG_MESSAGE, ACTION_SET_OPEN_FILE_REQUEST, ACTION_SET_TARGET_VIEW, ACTION_SET_VIEW, @@ -20,6 +25,10 @@ export interface ViewState { targetView: View | null; popupInfo: Array; openFileRequest: boolean; + importFileRequest: FileFormatInfo | null; + exportFileRequest: ExportType | null; + loading: boolean; + logMessage: Log | null; } export const initialViewState: ViewState = { @@ -27,6 +36,10 @@ export const initialViewState: ViewState = { targetView: null, popupInfo: [], openFileRequest: false, + importFileRequest: null, + exportFileRequest: null, + loading: false, + logMessage: null, }; export function viewState( @@ -65,6 +78,26 @@ export function viewState( ...state, openFileRequest: action.payload, }; + case ACTION_SET_IMPORT_FILE_REQUEST: + return { + ...state, + importFileRequest: action.payload, + }; + case ACTION_SET_EXPORT_FILE_REQUEST: + return { + ...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 b2eaa4e79..05a643a61 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 { ExportType, FileFormatInfo, Log } from '../../../shared/shared-types'; import { View } from '../../enums/enums'; import { PopupInfo, State } from '../../types/types'; @@ -37,3 +38,19 @@ 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; +} + +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; +} diff --git a/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts b/src/Frontend/util/__tests__/attribution-utils.test.ts similarity index 90% rename from src/Frontend/util/__tests__/get-attributions-with-resources.test.ts rename to src/Frontend/util/__tests__/attribution-utils.test.ts index f6bc87b0a..0d665ef6f 100644 --- a/src/Frontend/util/__tests__/get-attributions-with-resources.test.ts +++ b/src/Frontend/util/__tests__/attribution-utils.test.ts @@ -5,13 +5,15 @@ import { Attributions, AttributionsToResources, + ExportType, Resources, } from '../../../shared/shared-types'; import { getAttributionsWithAllChildResourcesWithoutFolders, getAttributionsWithResources, + getBomAttributions, removeSlashesFromFilesWithChildren, -} from '../get-attributions-with-resources'; +} from '../attribution-utils'; describe('getAttributionsWithResources', () => { it('returns attributions with resources', () => { @@ -438,3 +440,38 @@ describe('removeSlashesFromFilesWithChildren', () => { ).toEqual(expectedAttributionsWithResources); }); }); + +describe('getBomAttributions', () => { + 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, + ); + expect(detailedBomAttributions).toEqual({ + 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, + ); + expect(compactBomAttributions).toEqual({ + genericAttrib: { id: 'genericAttrib' }, + }); + }); +}); 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..efc9d7109 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'; @@ -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 + ), + ), + ); +} diff --git a/src/e2e-tests/__tests__/import-dialog.test.ts b/src/e2e-tests/__tests__/import-dialog.test.ts index 421da8c2a..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(); @@ -52,7 +52,7 @@ test('imports legacy opossum file', async ({ getDotOpossumFilePath(importDialog.legacyFilePath, ['json', 'json.gz']), ); - await menuBar.openImportLegacyOpossumFile(); + await menuBar.importLegacyOpossumFile(); await importDialog.assert.titleIsVisible(); await importDialog.inputFileSelection.click(); @@ -78,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(); @@ -93,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, }); 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',