diff --git a/locales/de-global.json5 b/locales/de-global.json5 index 0b3886e4..a71c8d62 100644 --- a/locales/de-global.json5 +++ b/locales/de-global.json5 @@ -13,6 +13,7 @@ label: 'Anwendungsmenü', login: 'Anmelden', logout: 'Abmelden', + upload: 'Daten Hochladen', imprint: 'Impressum', 'privacy-policy': 'Datenschutzerklärung', accessibility: 'Barrierefreiheit', @@ -134,4 +135,10 @@ 'loki-logo': 'LOKI-Logo', okay: 'Okay', yAxisLabel: 'Wert', + upload: { + header: 'Falldaten Hochladen', + dragNotice: 'Ziehen sie Ihre Datei(en) in diesen Bereich oder nutzen Sie den Knopf unten, um Ihre Falldaten an ESID zu senden.', + button: 'Daten hochladen', + dropNotice: 'Hier ablegen um die Datei(en) hochzuladen.', + }, } diff --git a/locales/en-global.json5 b/locales/en-global.json5 index 77ba7ae0..b5bfd00f 100644 --- a/locales/en-global.json5 +++ b/locales/en-global.json5 @@ -13,6 +13,7 @@ label: 'Application menu', login: 'Login', logout: 'Logout', + upload: 'Upload Data', imprint: 'Imprint', 'privacy-policy': 'Privacy Policy', accessibility: 'Accessibility', @@ -149,4 +150,10 @@ WIP: 'This functionality is still work in progress.', okay: 'Okay', yAxisLabel: 'Value', + upload: { + header: 'Upload Case Data', + dragNotice: 'Drag and drop your file(s) in here to or use the button below to uplad your case data to ESID.', + button: 'Upload Data', + dropNotice: 'Drop here to upload.', + }, } diff --git a/package-lock.json b/package-lock.json index 0d93e409..39767b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "i18next-browser-languagedetector": "7.2.0", "i18next-http-backend": "2.4.2", "json5": "2.2.3", + "ldrs": "1.0.1", "react": "18.3.1", "react-dom": "18.3.1", "react-i18next": "13.5.0", @@ -8426,6 +8427,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/ldrs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ldrs/-/ldrs-1.0.1.tgz", + "integrity": "sha512-eeCP1XEG72Yz6JX50zlYWRJcEfNBrpp8QSzyjD3HHhxrnt4steLX4iDHF3Dezjbnnj5qtuJn7lr1nln+/kpUIQ==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index e8d42fbc..2d8cbf8e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "i18next-browser-languagedetector": "7.2.0", "i18next-http-backend": "2.4.2", "json5": "2.2.3", + "ldrs": "1.0.1", "react": "18.3.1", "react-dom": "18.3.1", "react-i18next": "13.5.0", diff --git a/src/App.tsx b/src/App.tsx index 9c517aec..dffc3c6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ import {selectDistrict} from 'store/DataSelectionSlice'; import {I18nextProvider, useTranslation} from 'react-i18next'; import i18n from './util/i18n'; import {MUILocalization} from 'components/shared/MUILocalization'; - +import LoadingOverlay from './components/shared/LoadingOverlay'; import AuthProvider from './components/AuthProvider'; import BaseDataContext from 'context/BaseDataContext'; /** @@ -29,7 +29,10 @@ import BaseDataContext from 'context/BaseDataContext'; */ export default function App(): JSX.Element { return ( - + } + > diff --git a/src/components/LineChartContainer.tsx b/src/components/LineChartContainer.tsx index f9420bd6..8543aae3 100644 --- a/src/components/LineChartContainer.tsx +++ b/src/components/LineChartContainer.tsx @@ -105,7 +105,9 @@ export default function LineChartContainer() { const sum = pattern.reduce((acc, curr) => acc + curr, 0); for (let x = 0; x <= width; x += sum) { for (let i = 0; i < pattern.length; i++) { - html += `
`; + html += `
`; } } return html + ''; @@ -132,7 +134,10 @@ export default function LineChartContainer() { seriesId: `${id}-${filter.id}`, name: `
- ${generateHTMLStrokePattern(STROKE_PATTERNS[index % STROKE_PATTERNS.length], scenariosState[id]?.colors[0] ?? 'transparent')} + ${generateHTMLStrokePattern( + STROKE_PATTERNS[index % STROKE_PATTERNS.length], + scenariosState[id]?.colors[0] ?? 'transparent' + )} ${filter.name}
`, visible: true, diff --git a/src/components/ScenarioComponents/ScenarioContainer.tsx b/src/components/ScenarioComponents/ScenarioContainer.tsx index 2be9194a..196a59f1 100644 --- a/src/components/ScenarioComponents/ScenarioContainer.tsx +++ b/src/components/ScenarioComponents/ScenarioContainer.tsx @@ -90,8 +90,9 @@ export default function ScenarioContainer({minCompartmentsRows = 4, maxCompartme const model = simulationModels.find((model) => model.id === backendScenario?.modelId); const nodeList = nodeLists.find((nodeList) => nodeList.id === backendScenario?.nodeListId); const npiList = npis - .filter((npi) => - backendScenario?.linkedInterventions.find((intervention) => intervention.interventionId === npi.id) + .filter( + (npi) => + backendScenario?.linkedInterventions.find((intervention) => intervention.interventionId === npi.id) ) .map((npi) => tBackend(`interventions.${npi.name}`)); diff --git a/src/components/ScenarioComponents/ScenarioLibrary.tsx b/src/components/ScenarioComponents/ScenarioLibrary.tsx index 3ec7ef43..e62b0faa 100644 --- a/src/components/ScenarioComponents/ScenarioLibrary.tsx +++ b/src/components/ScenarioComponents/ScenarioLibrary.tsx @@ -282,8 +282,8 @@ function LibraryCard( const model = props.simulationModels.find((model) => model.id === backendScenario?.modelId); const nodeList = props.nodeLists.find((nodeList) => nodeList.id === backendScenario?.nodeListId); const npiList = props.npis - .filter((npi) => - backendScenario?.linkedInterventions.find((intervention) => intervention.interventionId === npi.id) + .filter( + (npi) => backendScenario?.linkedInterventions.find((intervention) => intervention.interventionId === npi.id) ) .map((npi) => tBackend(`interventions.${npi.name}`)); diff --git a/src/components/TopBar/ApplicationMenu.tsx b/src/components/TopBar/ApplicationMenu.tsx index 0424fc06..d171975b 100644 --- a/src/components/TopBar/ApplicationMenu.tsx +++ b/src/components/TopBar/ApplicationMenu.tsx @@ -14,6 +14,8 @@ import {useAppSelector} from 'store/hooks'; import {AuthContext, IAuthContext} from 'react-oauth2-code-pkce'; import CircularProgress from '@mui/material/CircularProgress'; +// Let's import pop-ups only once they are opened. +const DataUploadDialog = React.lazy(() => import('./PopUps/DataUploadDialog')); const ChangelogDialog = React.lazy(() => import('./PopUps/ChangelogDialog')); const ImprintDialog = React.lazy(() => import('./PopUps/ImprintDialog')); const PrivacyPolicyDialog = React.lazy(() => import('./PopUps/PrivacyPolicyDialog')); @@ -50,10 +52,15 @@ export default function ApplicationMenu(): JSX.Element { const [accessibilityOpen, setAccessibilityOpen] = React.useState(false); const [attributionsOpen, setAttributionsOpen] = React.useState(false); const [changelogOpen, setChangelogOpen] = React.useState(false); + const [uploadOpen, setUploadOpen] = React.useState(false); const keycloakLogout = () => { window.location.assign( - `${import.meta.env.VITE_OAUTH_API_URL}/realms/${realm}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURI(`${import.meta.env.VITE_OAUTH_REDIRECT_URL}`)}&id_token_hint=${idToken}` + `${ + import.meta.env.VITE_OAUTH_API_URL + }/realms/${realm}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURI( + `${import.meta.env.VITE_OAUTH_REDIRECT_URL}` + )}&id_token_hint=${idToken}` ); }; @@ -80,6 +87,12 @@ export default function ApplicationMenu(): JSX.Element { keycloakLogout(); }; + /** This method gets called, when the login menu entry was clicked. */ + const uploadClicked = () => { + closeMenu(); + setUploadOpen(true); + }; + /** This method gets called, when the imprint menu entry was clicked. It opens a dialog showing the legal text. */ const imprintClicked = () => { closeMenu(); @@ -135,6 +148,9 @@ export default function ApplicationMenu(): JSX.Element { )} + + {t('topBar.menu.upload')} + {t('topBar.menu.imprint')} {t('topBar.menu.privacy-policy')} {t('topBar.menu.accessibility')} @@ -142,6 +158,10 @@ export default function ApplicationMenu(): JSX.Element { {t('topBar.menu.changelog')} + setUploadOpen(false)}> + + + setImprintOpen(false)}> { + helix.register(); + }, []); + + const [uploadList, setUploadList] = React.useState<{fileInfo: string; file: File}[]>([]); + + const fileTypes: string[] = []; + + // Function to handle data upload. + const handleFiles = useCallback((fileList: FileList) => { + // Function to increase readability of file size appended behind filename. + const fileSizeToString = (size: number) => { + if (size < 1024) { + return `${size} B`; + } else if (size >= 1024 && size < 1048576) { + return `${(size / 1024).toFixed(1)} KB`; + } else { + return `${(size / 1048576).toFixed(1)} MB`; + } + }; + // Update file display with new files. + const displayList: {fileInfo: string; file: File}[] = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + + displayList.push({ + fileInfo: `${file.name} (${fileSizeToString(file.size)})`, + file: file, + }); + } + setUploadList(displayList); + }, []); + + // Callback for drag event (to modify styling). + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true); + } else if (e.type === 'dragleave') { + setDragActive(false); + } + }, []); + + // Callback for files selected through drag & drop. + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFiles(e.dataTransfer.files); + } + }, + [handleFiles] + ); + + // Callback for files selected through dialog. + const handleClick = useCallback( + (e: React.ChangeEvent) => { + e.preventDefault(); + if (e.target.files && e.target.files[0]) { + handleFiles(e.target.files); + } + }, + [handleFiles] + ); + + return ( +
e.preventDefault()}> + + + {t('upload.header')} +
{t('upload.dragNotice')}
+ {uploadList.length > 0 && ( + + {uploadList.map((item) => ( + // Create a list item for each file. + + ))} + + )} + +
+ {dragActive && ( + // Add an overlay on top of the popup to display a notice and make handling the drag events smoother. +
+ + {t('upload.dropNotice')} + +
+ )} +
+ ); +} + +function FileItem({fileInfo, file}: {fileInfo: string; file: File}): JSX.Element { + const theme = useTheme(); + const {isSuccess, isError} = useSendCasedataFileQuery(file); + + return ( + + ) : isError ? ( + + ) : ( + + ) + } + > + + + ); +} diff --git a/src/components/shared/LoadingContainer.tsx b/src/components/shared/LoadingContainer.tsx index e1d8bd44..0871dfa5 100644 --- a/src/components/shared/LoadingContainer.tsx +++ b/src/components/shared/LoadingContainer.tsx @@ -5,15 +5,22 @@ import React from 'react'; import Box from '@mui/material/Box'; import LoadingOverlay from './LoadingOverlay'; import {SxProps} from '@mui/system'; +import {useTheme} from '@mui/material/styles'; /** * This is a wrapper component for a container that can have a loading indicator overlayed. */ export default function LoadingContainer(props: LoadingContainerProps): JSX.Element { + const theme = useTheme(); + return ( {props.children} - + ); } @@ -28,6 +35,9 @@ interface LoadingContainerProps { /** The color of the overlay. */ overlayColor: string; + /** The color of the throbber. Theme primary color by default. */ + throbberColor?: string; + /** React prop to allow nesting components. Do not set manually. */ children: React.ReactNode; } diff --git a/src/components/shared/LoadingOverlay.tsx b/src/components/shared/LoadingOverlay.tsx index b1be0387..e9f8de88 100644 --- a/src/components/shared/LoadingOverlay.tsx +++ b/src/components/shared/LoadingOverlay.tsx @@ -1,15 +1,24 @@ // SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, {useEffect} from 'react'; import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; +import {helix} from 'ldrs'; /** * Overlays a loading indicator over the previously declared components. It is recommended to use the LoadingContainer * component instead, since it ensures correct ordering and layouting. */ -export default function LoadingOverlay(props: {show: boolean; overlayColor: string}): JSX.Element { +export default function LoadingOverlay(props: { + show: boolean; + overlayColor: string; + throbberColor: string; +}): JSX.Element { + // register helix throbber + useEffect(() => { + helix.register(); + }, []); + return props.show ? ( - + ) : ( <> diff --git a/src/store/index.ts b/src/store/index.ts index 07a29885..1b1960bc 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,6 +4,7 @@ import {combineReducers, configureStore} from '@reduxjs/toolkit'; import DataSelectionReducer from './DataSelectionSlice'; import {scenarioApi} from './services/scenarioApi'; +import {utilsApi} from './services/utilsApi'; import UserPreferenceReducer from './UserPreferenceSlice'; import {WebStorage, persistReducer, persistStore} from 'redux-persist'; import storage from 'redux-persist/lib/storage'; @@ -29,6 +30,7 @@ const rootReducer = combineReducers({ realm: RealmReducer, auth: AuthReducer, [scenarioApi.reducerPath]: scenarioApi.reducer, + [utilsApi.reducerPath]: utilsApi.reducer, [idpApi.reducerPath]: idpApi.reducer, }); @@ -44,7 +46,7 @@ export const Store = configureStore({ serializableCheck: { ignoredActions: ['persist/PERSIST'], }, - }).concat(scenarioApi.middleware, idpApi.middleware), + }).concat(scenarioApi.middleware, utilsApi.middleware, idpApi.middleware), }); export const Persistor = persistStore(Store); diff --git a/src/store/services/utilsApi.ts b/src/store/services/utilsApi.ts new file mode 100644 index 00000000..1abf0765 --- /dev/null +++ b/src/store/services/utilsApi.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) +// SPDX-License-Identifier: Apache-2.0 + +import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react'; +import {RootState} from 'store'; + +export const utilsApi = createApi({ + reducerPath: 'utilsApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${import.meta.env.VITE_API_URL || ''}`, + prepareHeaders: (headers, {getState}) => { + const realm = (getState() as RootState).realm; + const auth = (getState() as RootState).auth; + + if (realm.name && realm.name !== '') { + headers.set('x-realm', realm.name); + } + + if (auth.token && auth.token !== '') { + headers.set('Authorization', 'Bearer ' + auth.token); + } + + return headers; + }, + }), + + endpoints: (build) => ({ + sendCasedataFile: build.query({ + query(file: File) { + const formData = new FormData(); + console.log('file object for request:', file); + formData.append('file', file); + formData.append('type', file.type); + console.log('formData:', formData); + return { + url: `utils/share/casedata`, + method: 'POST', + body: formData, + }; + }, + }), + }), +}); + +export const {useSendCasedataFileQuery} = utilsApi; diff --git a/test/components/Sidebar/SearchBar.test.tsx b/test/components/Sidebar/SearchBar.test.tsx index 23e222d5..0a38b447 100644 --- a/test/components/Sidebar/SearchBar.test.tsx +++ b/test/components/Sidebar/SearchBar.test.tsx @@ -61,7 +61,11 @@ const SearchBarTest = () => { setSelectedArea(option); } }} - placeholder={`${selectedArea!['GEN' as keyof GeoJsonProperties]}${selectedArea!['BEZ' as keyof GeoJsonProperties] ? ` (BEZ.${selectedArea!['BEZ' as keyof GeoJsonProperties]})` : ''}`} + placeholder={`${selectedArea!['GEN' as keyof GeoJsonProperties]}${ + selectedArea!['BEZ' as keyof GeoJsonProperties] + ? ` (BEZ.${selectedArea!['BEZ' as keyof GeoJsonProperties]})` + : '' + }`} optionEqualProperty='RS' valueEqualProperty='RS' />