diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4306bc73..70b65cb2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Added Config feature flags for Advanced Search Options and Cart
- Added draw boundary feature to allow user to draw polygon on map (WIP)
- When polygon drawn, use as search intersects param instead of map viewport bbox
+- Added upload geojson feature to allow users to select a geojson file to add to map
+- Reusable System Message component for showing app alerts
### Removed
diff --git a/package-lock.json b/package-lock.json
index 02c42d69..14356309 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"leaflet-geosearch": "^3.8.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-dropzone": "^14.2.3",
"react-leaflet": "^4.2.1",
"react-redux": "^8.0.7",
"react-tooltip": "^5.16.1"
@@ -3241,6 +3242,14 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
+ "node_modules/attr-accept": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
+ "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
@@ -5249,6 +5258,17 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/file-selector": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
+ "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -8992,6 +9012,22 @@
"react": "^18.2.0"
}
},
+ "node_modules/react-dropzone": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
+ "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
+ "dependencies": {
+ "attr-accept": "^2.2.2",
+ "file-selector": "^0.6.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8 || 18.0.0"
+ }
+ },
"node_modules/react-fit": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/react-fit/-/react-fit-1.5.1.tgz",
@@ -11211,8 +11247,7 @@
"node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
- "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==",
- "dev": true
+ "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"node_modules/tsutils": {
"version": "3.21.0",
@@ -14607,6 +14642,11 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
+ "attr-accept": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
+ "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
+ },
"available-typed-arrays": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
@@ -16001,6 +16041,14 @@
"flat-cache": "^3.0.4"
}
},
+ "file-selector": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
+ "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -18658,6 +18706,16 @@
"scheduler": "^0.23.0"
}
},
+ "react-dropzone": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
+ "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
+ "requires": {
+ "attr-accept": "^2.2.2",
+ "file-selector": "^0.6.0",
+ "prop-types": "^15.8.1"
+ }
+ },
"react-fit": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/react-fit/-/react-fit-1.5.1.tgz",
@@ -20355,8 +20413,7 @@
"tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
- "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==",
- "dev": true
+ "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"tsutils": {
"version": "3.21.0",
diff --git a/package.json b/package.json
index 1267bedf..ddeddc76 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"leaflet-geosearch": "^3.8.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-dropzone": "^14.2.3",
"react-leaflet": "^4.2.1",
"react-redux": "^8.0.7",
"react-tooltip": "^5.16.1"
diff --git a/src/App.jsx b/src/App.jsx
index c4f4649b..3015dfaa 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -7,6 +7,8 @@ import PageHeader from './components/Layout/PageHeader/PageHeader'
import PublishModal from './components/PublishModal/PublishModal'
import LaunchModal from './components/LaunchModal/LaunchModal'
import LaunchImageModal from './components/LaunchModal/LaunchImageModal'
+import UploadGeojsonModal from './components/UploadGeojsonModal/UploadGeojsonModal'
+import SystemMessage from './components/SystemMessage/SystemMessage'
import { GetCollectionsService } from './services/get-collections-service'
import { useSelector } from 'react-redux'
@@ -21,6 +23,12 @@ function App() {
const _showLaunchImageModal = useSelector(
(state) => state.mainSlice.showLaunchImageModal
)
+ const _showUploadGeojsonModal = useSelector(
+ (state) => state.mainSlice.showUploadGeojsonModal
+ )
+ const _showApplicationAlert = useSelector(
+ (state) => state.mainSlice.showApplicationAlert
+ )
useEffect(() => {
GetCollectionsService()
}, [])
@@ -33,6 +41,10 @@ function App() {
{_showPublishModal ? : null}
{_showLaunchModal ? : null}
{_showLaunchImageModal ? : null}
+ {_showUploadGeojsonModal ? (
+
+ ) : null}
+ {_showApplicationAlert ? : null}
)
diff --git a/src/App.test.jsx b/src/App.test.jsx
index e899eb1a..6a3abb5f 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -6,7 +6,9 @@ import { store } from './redux/store'
import {
setShowPublishModal,
setShowLaunchModal,
- setShowLaunchImageModal
+ setShowLaunchImageModal,
+ setshowUploadGeojsonModal,
+ setshowApplicationAlert
} from './redux/slices/mainSlice'
import { vi } from 'vitest'
import * as CollectionsService from './services/get-collections-service'
@@ -81,5 +83,35 @@ describe('App', () => {
expect(LaunchImageModalComponent).not.toBeNull()
})
})
+ describe('when conditionally rendering UploadGeojsonModal', () => {
+ it('should not render UploadGeojsonModal if showUploadGeojsonModal in state is false', () => {
+ setup()
+ const UploadGeojsonModalComponent = screen.queryByTestId(
+ 'testUploadGeojsonModal'
+ )
+ expect(UploadGeojsonModalComponent).toBeNull()
+ })
+ it('should render UploadGeojsonModal if showUploadGeojsonModal in state is true', () => {
+ store.dispatch(setshowUploadGeojsonModal(true))
+ setup()
+ const UploadGeojsonModalComponent = screen.queryByTestId(
+ 'testUploadGeojsonModal'
+ )
+ expect(UploadGeojsonModalComponent).not.toBeNull()
+ })
+ })
+ describe('when conditionally rendering SystemMessage', () => {
+ it('should not render SystemMessage if showApplicationAlert in state is false', () => {
+ setup()
+ const SystemMessageComponent = screen.queryByTestId('testSystemMessage')
+ expect(SystemMessageComponent).toBeNull()
+ })
+ it('should render SystemMessage if showApplicationAlert in state is true', () => {
+ store.dispatch(setshowApplicationAlert(true))
+ setup()
+ const SystemMessageComponent = screen.queryByTestId('testSystemMessage')
+ expect(SystemMessageComponent).not.toBeNull()
+ })
+ })
})
})
diff --git a/src/components/Layout/Content/BottomContent/BottomContent.css b/src/components/Layout/Content/BottomContent/BottomContent.css
index 12972dbb..27327633 100644
--- a/src/components/Layout/Content/BottomContent/BottomContent.css
+++ b/src/components/Layout/Content/BottomContent/BottomContent.css
@@ -1,9 +1,10 @@
.BottomContent {
width: 100%;
- height: calc(100% - 103px);
+ height: calc(100% - 100px);
background-color: #12171a;
display: flex;
- position: relative;
+ position: absolute;
+ top: 100px;
}
.ZoomNotice {
diff --git a/src/components/Layout/Content/TopContent/TopContent.css b/src/components/Layout/Content/TopContent/TopContent.css
index 50aa3e06..79f8c2ed 100644
--- a/src/components/Layout/Content/TopContent/TopContent.css
+++ b/src/components/Layout/Content/TopContent/TopContent.css
@@ -2,8 +2,8 @@
width: 100%;
height: 100px;
background-color: #353d4f;
- position: relative;
- z-index: 5;
+ position: absolute;
+ z-index: 6;
}
.TopContent .mobileMenu {
diff --git a/src/components/Search/Search.Advanced.test.jsx b/src/components/Search/Search.Advanced.test.jsx
index 1b522dae..8decfaaf 100644
--- a/src/components/Search/Search.Advanced.test.jsx
+++ b/src/components/Search/Search.Advanced.test.jsx
@@ -151,6 +151,36 @@ describe('Search', () => {
expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy()
})
})
+ describe('when upload geojson button clicked', () => {
+ it('should not call dispatch functions if geom already exists', async () => {
+ store.dispatch(
+ setsearchGeojsonBoundary({
+ type: 'Polygon',
+ coordinates: [[]]
+ })
+ )
+ setup()
+ const advancedButton = screen.getByText(/advanced/i)
+ await user.click(advancedButton)
+ const uploadGeojsonButton = screen.getByRole('button', {
+ name: /upload geojson/i
+ })
+ await user.click(uploadGeojsonButton)
+ expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy()
+ })
+ it('should call dispatch functions if geom does not exists', async () => {
+ store.dispatch(setshowAdvancedSearchOptions(true))
+ setup()
+ const advancedButton = screen.getByText(/advanced/i)
+ await user.click(advancedButton)
+ const uploadGeojsonButton = screen.getByRole('button', {
+ name: /upload geojson/i
+ })
+ await user.click(uploadGeojsonButton)
+ expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy()
+ expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy()
+ })
+ })
describe('when drawing mode enabled', () => {
it('should render disabled search bar overlay div', async () => {
setup()
diff --git a/src/components/Search/Search.jsx b/src/components/Search/Search.jsx
index 57d3bdf7..89471bc5 100644
--- a/src/components/Search/Search.jsx
+++ b/src/components/Search/Search.jsx
@@ -5,7 +5,8 @@ import {
setIsAutoSearchSet,
setshowAdvancedSearchOptions,
setisDrawingEnabled,
- setsearchGeojsonBoundary
+ setsearchGeojsonBoundary,
+ setshowUploadGeojsonModal
} from '../../redux/slices/mainSlice'
import 'react-tooltip/dist/react-tooltip.css'
import Switch from '@mui/material/Switch'
@@ -79,6 +80,14 @@ const Search = () => {
enableMapPolyDrawing()
}
+ function onUploadGeojsonButtonClicked() {
+ if (_searchGeojsonBoundary) {
+ return
+ }
+ dispatch(setshowAdvancedSearchOptions(false))
+ dispatch(setshowUploadGeojsonModal(true))
+ }
+
function onClearButtonClicked() {
if (!_searchGeojsonBoundary) {
return
@@ -164,6 +173,7 @@ const Search = () => {
: 'advancedSearchOptionsButton ' +
'advancedSearchOptionsButtonDisabled'
}
+ onClick={onUploadGeojsonButtonClicked}
>
Upload GeoJSON
diff --git a/src/components/SystemMessage/SystemMessage.css b/src/components/SystemMessage/SystemMessage.css
new file mode 100644
index 00000000..a5f8cd60
--- /dev/null
+++ b/src/components/SystemMessage/SystemMessage.css
@@ -0,0 +1,6 @@
+.SystemMessage {
+ position: absolute;
+ bottom: 40px;
+ right: 10px;
+ z-index: 500;
+}
diff --git a/src/components/SystemMessage/SystemMessage.jsx b/src/components/SystemMessage/SystemMessage.jsx
new file mode 100644
index 00000000..25f87481
--- /dev/null
+++ b/src/components/SystemMessage/SystemMessage.jsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import Alert from '@mui/material/Alert'
+import './SystemMessage.css'
+
+import { useSelector } from 'react-redux'
+import { store } from '../../redux/store'
+import { setshowApplicationAlert } from '../../redux/slices/mainSlice'
+
+const SystemMessage = () => {
+ const _applicationAlertMessage = useSelector(
+ (state) => state.mainSlice.applicationAlertMessage
+ )
+ const _applicationAlertSeverity = useSelector(
+ (state) => state.mainSlice.applicationAlertSeverity
+ )
+
+ return (
+
+
{
+ store.dispatch(setshowApplicationAlert(false))
+ }}
+ severity={_applicationAlertSeverity}
+ sx={{
+ '& .MuiAlert-message': {
+ fontSize: 14
+ }
+ }}
+ >
+ {_applicationAlertMessage}
+
+
+ )
+}
+
+export default SystemMessage
diff --git a/src/components/SystemMessage/SystemMessage.test.jsx b/src/components/SystemMessage/SystemMessage.test.jsx
new file mode 100644
index 00000000..0e1300e6
--- /dev/null
+++ b/src/components/SystemMessage/SystemMessage.test.jsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import SystemMessage from './SystemMessage'
+import { Provider } from 'react-redux'
+import { store } from '../../redux/store'
+import {
+ setapplicationAlertMessage,
+ setapplicationAlertSeverity,
+ setshowApplicationAlert
+} from '../../redux/slices/mainSlice'
+
+describe('SystemMessage', () => {
+ const user = userEvent.setup()
+
+ const setup = () =>
+ render(
+
+
+
+ )
+
+ describe('when user clicks close button', () => {
+ it('should set ShowApplicationAlert in state to false', async () => {
+ store.dispatch(setshowApplicationAlert(true))
+ setup()
+ await user.click(
+ screen.getByRole('button', {
+ name: /close/i
+ })
+ )
+ expect(store.getState().mainSlice.showApplicationAlert).toBeFalsy()
+ })
+ })
+
+ describe('when alert renders', () => {
+ it('should set severity to reflect redux state', async () => {
+ store.dispatch(setshowApplicationAlert(true))
+ store.dispatch(setapplicationAlertSeverity('success'))
+ setup()
+ expect(screen.getByRole('alert')).toHaveClass('MuiAlert-standardSuccess')
+ })
+ it('should set message to reflect redux state', async () => {
+ store.dispatch(setshowApplicationAlert(true))
+ store.dispatch(setapplicationAlertMessage('user updated'))
+ setup()
+ expect(screen.getByText(/user updated/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/UploadGeojsonModal/UploadGeojsonModal.css b/src/components/UploadGeojsonModal/UploadGeojsonModal.css
new file mode 100644
index 00000000..28c5ff26
--- /dev/null
+++ b/src/components/UploadGeojsonModal/UploadGeojsonModal.css
@@ -0,0 +1,85 @@
+.uploadGeojsonModal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 100;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.uploadGeojsonModalContainer {
+ width: 600px;
+ max-width: 80%;
+ min-height: 280px;
+ background: #353d4f;
+ color: #dedede;
+ padding: 20px;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.uploadGeojsonModalTitle {
+ font-size: 24px;
+ font-weight: bold;
+ position: absolute;
+ top: 20px;
+ left: 20px;
+}
+
+.fileToUploadText {
+ position: absolute;
+ bottom: 25px;
+ left: 20px;
+ right: 140px;
+ width: calc(100% - 220px);
+ white-space: pre-wrap;
+ white-space: -moz-pre-wrap;
+ white-space: -pre-wrap;
+ white-space: -o-pre-wrap;
+ word-wrap: break-word;
+}
+
+.uploadGeojsonModalActionButtons {
+ position: absolute;
+ right: 20px;
+ display: flex;
+ flex-direction: row;
+ bottom: 10px;
+}
+
+.uploadGeojsonModalActionButton {
+ height: 32px;
+ width: 50px;
+ background-color: #4f5768;
+ border: none;
+ color: white;
+ padding: 15px 32px;
+ text-align: center;
+ text-decoration: none;
+ font-size: 16px;
+ cursor: pointer;
+ border: 1px solid #a9b0c1;
+ margin-bottom: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 5px;
+ margin-left: 10px;
+}
+
+.uploadGeojsonModalActionButton:hover {
+ background: #353d4f;
+}
+
+@media (max-width: 650px) {
+ .fileToUploadText {
+ font-size: 14px;
+ }
+}
diff --git a/src/components/UploadGeojsonModal/UploadGeojsonModal.jsx b/src/components/UploadGeojsonModal/UploadGeojsonModal.jsx
new file mode 100644
index 00000000..a48ebe5d
--- /dev/null
+++ b/src/components/UploadGeojsonModal/UploadGeojsonModal.jsx
@@ -0,0 +1,163 @@
+import React, { useMemo, useState } from 'react'
+import './UploadGeojsonModal.css'
+import { useDispatch } from 'react-redux'
+import { setshowUploadGeojsonModal } from '../../redux/slices/mainSlice'
+import { useDropzone } from 'react-dropzone'
+import { addUploadedGeojsonToMap, parseGeomUpload } from '../../utils/mapHelper'
+import { showApplicationAlert } from '../../utils/alertHelper'
+
+const UploadGeojsonModal = () => {
+ const [fileData, setFileData] = useState(null)
+ const dispatch = useDispatch()
+
+ const baseStyle = {
+ padding: '20px',
+ borderWidth: 2,
+ borderRadius: 2,
+ borderColor: '#6cc24a',
+ borderStyle: 'dashed',
+ backgroundColor: '#4f5768',
+ color: '#fff',
+ outline: 'none',
+ transition: 'border .24s ease-in-out',
+ height: '80px',
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer'
+ }
+
+ const focusedStyle = {
+ borderColor: '#6cc24a'
+ }
+
+ const acceptStyle = {
+ borderColor: '#00e676'
+ }
+
+ const rejectStyle = {
+ borderColor: '#ff1744'
+ }
+
+ const handleFileDrop = (acceptedFiles) => {
+ const file = acceptedFiles[0]
+ if (file.size >= 100000) {
+ setFileData(null)
+ showApplicationAlert('error', 'File size exceeded (100KB max)', 5000)
+ return
+ }
+ if (file.name.endsWith('.geojson') || file.name.endsWith('.json')) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ setFileData(e.target.result)
+ }
+ reader.readAsText(file)
+ } else {
+ setFileData(null)
+ showApplicationAlert(
+ 'error',
+ 'ERROR: Only .json or .geojson supported',
+ 5000
+ )
+ }
+ }
+
+ const {
+ getRootProps,
+ getInputProps,
+ isFocused,
+ isDragAccept,
+ isDragReject,
+ acceptedFiles
+ } = useDropzone({
+ onDrop: handleFileDrop,
+ multiple: false
+ })
+
+ const acceptedFileItems = acceptedFiles.map((file) => (
+ {file.path}
+ ))
+
+ const style = useMemo(
+ () => ({
+ ...baseStyle,
+ ...(isFocused ? focusedStyle : {}),
+ ...(isDragAccept ? acceptStyle : {}),
+ ...(isDragReject ? rejectStyle : {})
+ }),
+ [isFocused, isDragAccept, isDragReject]
+ )
+
+ function onUploadGeojsonCancelClicked() {
+ dispatch(setshowUploadGeojsonModal(false))
+ }
+
+ async function onUploadGeojsonAddClicked() {
+ let geoJsonData
+ try {
+ geoJsonData = JSON.parse(fileData)
+ } catch (e) {
+ showApplicationAlert('error', 'ERROR: JSON format invalid', 5000)
+ return
+ }
+ if (fileData) {
+ await parseGeomUpload(geoJsonData).then(
+ (response) => {
+ addUploadedGeojsonToMap(response)
+ dispatch(setshowUploadGeojsonModal(false))
+ },
+ // eslint-disable-next-line n/handle-callback-err
+ (error) => {
+ showApplicationAlert(
+ 'error',
+ 'ERROR: ' + error.message.toString(),
+ 5000
+ )
+ }
+ )
+ } else {
+ showApplicationAlert('error', 'No file selected', 5000)
+ }
+ }
+
+ return (
+
+
+
+
Upload Geojson File
+
+
+ Drag and drop a GeoJSON file here or click to{' '}
+
+ browse
+
+
+ {fileData ? (
+
{acceptedFileItems}
+ ) : null}
+
+
+
+
+
+
+ )
+}
+
+export default UploadGeojsonModal
diff --git a/src/components/UploadGeojsonModal/UploadGeojsonModal.test.jsx b/src/components/UploadGeojsonModal/UploadGeojsonModal.test.jsx
new file mode 100644
index 00000000..eec3148f
--- /dev/null
+++ b/src/components/UploadGeojsonModal/UploadGeojsonModal.test.jsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, waitFor, fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import UploadGeojsonModal from './UploadGeojsonModal'
+import { Provider } from 'react-redux'
+import { store } from '../../redux/store'
+import { setshowUploadGeojsonModal } from '../../redux/slices/mainSlice'
+import * as alertHelper from '../../utils/alertHelper'
+import * as mapHelper from '../../utils/mapHelper'
+
+describe('UploadGeojsonModal', () => {
+ const user = userEvent.setup()
+
+ const setup = () =>
+ render(
+
+
+
+ )
+
+ describe('when cancel button is clicked', () => {
+ it('should set showUploadGeojsonModal to be false in state', async () => {
+ store.dispatch(setshowUploadGeojsonModal(true))
+ setup()
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+ await user.click(cancelButton)
+ expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy()
+ })
+ })
+ describe('when upload button is clicked', () => {
+ beforeEach(() => {
+ vi.mock('../../utils/alertHelper')
+ vi.mock('../../utils/mapHelper')
+ })
+ afterEach(() => {
+ vi.resetAllMocks()
+ })
+ it('should show error if json is not valid and not close modal', async () => {
+ const spyshowApplicationAlert = vi.spyOn(
+ alertHelper,
+ 'showApplicationAlert'
+ )
+ store.dispatch(setshowUploadGeojsonModal(true))
+ setup()
+ const json = '{ "invalid json"'
+ const file = new File([json], 'test.geojson', {
+ type: 'application/json'
+ })
+ const input = screen.getByTestId('testGeojsonFileUploadInput')
+ await waitFor(async () =>
+ fireEvent.change(input, { target: { files: [file] } })
+ )
+ const cancelButton = screen.getByRole('button', { name: /add/i })
+ await user.click(cancelButton)
+ expect(spyshowApplicationAlert).toHaveBeenCalledWith(
+ 'error',
+ 'ERROR: JSON format invalid',
+ 5000
+ )
+ expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy()
+ })
+ it('should show error and not close modal if parseGeomUpload throws an error', async () => {
+ const spyshowApplicationAlert = vi.spyOn(
+ alertHelper,
+ 'showApplicationAlert'
+ )
+ const spyaddUploadedGeojsonToMap = vi.spyOn(
+ mapHelper,
+ 'addUploadedGeojsonToMap'
+ )
+ mapHelper.parseGeomUpload.mockRejectedValueOnce(new Error('Parse error'))
+
+ store.dispatch(setshowUploadGeojsonModal(true))
+ setup()
+ const geojson = JSON.stringify({
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {
+ name: 'My Point'
+ }
+ })
+ const file = new File([geojson], 'test.geojson', {
+ type: 'application/json'
+ })
+ const input = screen.getByTestId('testGeojsonFileUploadInput')
+ await waitFor(async () =>
+ fireEvent.change(input, { target: { files: [file] } })
+ )
+ const cancelButton = screen.getByRole('button', { name: /add/i })
+ await user.click(cancelButton)
+ expect(spyshowApplicationAlert).toHaveBeenCalledWith(
+ 'error',
+ 'ERROR: Parse error',
+ 5000
+ )
+ expect(spyaddUploadedGeojsonToMap).not.toHaveBeenCalled()
+ expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy()
+ })
+ it('should call addUploadedGeojsonToMap and close modal if parseGeomUpload does not error', async () => {
+ const spyshowApplicationAlert = vi.spyOn(
+ alertHelper,
+ 'showApplicationAlert'
+ )
+ const spyaddUploadedGeojsonToMap = vi.spyOn(
+ mapHelper,
+ 'addUploadedGeojsonToMap'
+ )
+ mapHelper.parseGeomUpload.mockResolvedValueOnce('parsed geojson')
+
+ store.dispatch(setshowUploadGeojsonModal(true))
+ setup()
+ const geojson = JSON.stringify({
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {
+ name: 'My Point'
+ }
+ })
+ const file = new File([geojson], 'test.geojson', {
+ type: 'application/json'
+ })
+ const input = screen.getByTestId('testGeojsonFileUploadInput')
+ await waitFor(async () =>
+ fireEvent.change(input, { target: { files: [file] } })
+ )
+ const cancelButton = screen.getByRole('button', { name: /add/i })
+ await user.click(cancelButton)
+ expect(spyshowApplicationAlert).not.toHaveBeenCalled()
+ expect(spyaddUploadedGeojsonToMap).toHaveBeenCalledWith('parsed geojson')
+ expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy()
+ })
+ })
+})
diff --git a/src/redux/slices/mainSlice.js b/src/redux/slices/mainSlice.js
index 4410a421..2caafed7 100644
--- a/src/redux/slices/mainSlice.js
+++ b/src/redux/slices/mainSlice.js
@@ -31,7 +31,11 @@ const initialState = {
showAdvancedSearchOptions: false,
isDrawingEnabled: false,
mapDrawPolygonHandler: null,
- searchGeojsonBoundary: null
+ searchGeojsonBoundary: null,
+ showUploadGeojsonModal: false,
+ showApplicationAlert: false,
+ applicationAlertMessage: 'System Error',
+ applicationAlertSeverity: 'error'
}
// next, for every key in the initialState
@@ -123,6 +127,18 @@ export const mainSlice = createSlice({
},
setsearchGeojsonBoundary: (state, action) => {
state.searchGeojsonBoundary = action.payload
+ },
+ setshowUploadGeojsonModal: (state, action) => {
+ state.showUploadGeojsonModal = action.payload
+ },
+ setshowApplicationAlert: (state, action) => {
+ state.showApplicationAlert = action.payload
+ },
+ setapplicationAlertMessage: (state, action) => {
+ state.applicationAlertMessage = action.payload
+ },
+ setapplicationAlertSeverity: (state, action) => {
+ state.applicationAlertSeverity = action.payload
}
}
})
@@ -157,5 +173,9 @@ export const { setshowAdvancedSearchOptions } = mainSlice.actions
export const { setisDrawingEnabled } = mainSlice.actions
export const { setmapDrawPolygonHandler } = mainSlice.actions
export const { setsearchGeojsonBoundary } = mainSlice.actions
+export const { setshowUploadGeojsonModal } = mainSlice.actions
+export const { setshowApplicationAlert } = mainSlice.actions
+export const { setapplicationAlertMessage } = mainSlice.actions
+export const { setapplicationAlertSeverity } = mainSlice.actions
export default mainSlice.reducer
diff --git a/src/utils/alertHelper.js b/src/utils/alertHelper.js
new file mode 100644
index 00000000..c976dcf0
--- /dev/null
+++ b/src/utils/alertHelper.js
@@ -0,0 +1,24 @@
+import {
+ setapplicationAlertMessage,
+ setapplicationAlertSeverity,
+ setshowApplicationAlert
+} from '../redux/slices/mainSlice'
+import { store } from '../redux/store'
+
+export function showApplicationAlert(
+ severity,
+ message = null,
+ duration = null
+) {
+ message
+ ? store.dispatch(setapplicationAlertMessage(message))
+ : store.dispatch(setapplicationAlertMessage('System Error'))
+
+ store.dispatch(setapplicationAlertSeverity(severity))
+ store.dispatch(setshowApplicationAlert(true))
+
+ duration &&
+ setTimeout(() => {
+ store.dispatch(setshowApplicationAlert(false))
+ }, duration)
+}
diff --git a/src/utils/alertHelper.test.js b/src/utils/alertHelper.test.js
new file mode 100644
index 00000000..b88017ee
--- /dev/null
+++ b/src/utils/alertHelper.test.js
@@ -0,0 +1,39 @@
+import { vi } from 'vitest'
+import { setapplicationAlertMessage } from '../redux/slices/mainSlice'
+import { store } from '../redux/store'
+import { showApplicationAlert } from './alertHelper'
+
+describe('AlertHelper', () => {
+ describe('showApplicationAlert', () => {
+ it('should set default Alert message if no message passed', () => {
+ store.dispatch(setapplicationAlertMessage('test error'))
+ expect(store.getState().mainSlice.applicationAlertMessage).toBe(
+ 'test error'
+ )
+ showApplicationAlert('error', null)
+ expect(store.getState().mainSlice.applicationAlertMessage).toBe(
+ 'System Error'
+ )
+ })
+ it('should set Alert message in state if message param passed', () => {
+ showApplicationAlert('error', 'this is a user message')
+ expect(store.getState().mainSlice.applicationAlertMessage).toBe(
+ 'this is a user message'
+ )
+ })
+ it('should set ShowApplicationAlert in state to false if duration param passed and specified duration has finished', async () => {
+ vi.useFakeTimers()
+ const durationParam = 5000
+ showApplicationAlert('error', null, durationParam)
+ expect(store.getState().mainSlice.showApplicationAlert).toBeTruthy()
+ vi.runAllTimers()
+ expect(store.getState().mainSlice.showApplicationAlert).toBeFalsy()
+ })
+ it('should set ApplicationAlertSeverity in state to match input param', () => {
+ showApplicationAlert('success', 'this is a user message')
+ expect(store.getState().mainSlice.applicationAlertSeverity).toBe(
+ 'success'
+ )
+ })
+ })
+})
diff --git a/src/utils/geojsonValidation.js b/src/utils/geojsonValidation.js
new file mode 100644
index 00000000..97730f3d
--- /dev/null
+++ b/src/utils/geojsonValidation.js
@@ -0,0 +1,93 @@
+const GeoJSONValidation = {
+ validate: function (geojson) {
+ if (
+ typeof geojson === 'object' &&
+ geojson !== null &&
+ 'type' in geojson &&
+ [
+ 'Point',
+ 'MultiPoint',
+ 'LineString',
+ 'MultiLineString',
+ 'Polygon',
+ 'MultiPolygon',
+ 'GeometryCollection',
+ 'Feature',
+ 'FeatureCollection'
+ ].includes(geojson.type)
+ ) {
+ return true
+ }
+ return false
+ },
+
+ isValidGeoJSON: function (geojson) {
+ return this.validate(geojson)
+ },
+
+ isValidFeatureCollection: function (featureCollection) {
+ return (
+ featureCollection.type === 'FeatureCollection' &&
+ Array.isArray(featureCollection.features) &&
+ featureCollection.features.every(
+ (feature) =>
+ feature.type === 'Feature' &&
+ typeof feature.properties === 'object' &&
+ feature.properties !== null &&
+ typeof feature.geometry === 'object' &&
+ feature.geometry !== null &&
+ this.isValidGeoJSON(feature.geometry)
+ )
+ )
+ },
+
+ isValidFeature: function (feature) {
+ return (
+ feature.type === 'Feature' &&
+ typeof feature.properties === 'object' &&
+ feature.properties !== null &&
+ typeof feature.geometry === 'object' &&
+ feature.geometry !== null &&
+ this.isValidGeoJSON(feature.geometry)
+ )
+ },
+
+ isValidGeometry: function (geometry) {
+ const validTypes = [
+ 'Point',
+ 'MultiPoint',
+ 'LineString',
+ 'MultiLineString',
+ 'Polygon',
+ 'MultiPolygon',
+ 'GeometryCollection'
+ ]
+
+ if (!validTypes.includes(geometry.type)) {
+ return false
+ }
+
+ if (geometry.type === 'GeometryCollection') {
+ return (
+ Array.isArray(geometry.geometries) &&
+ geometry.geometries.every(this.isValidGeometry)
+ )
+ }
+
+ return (
+ typeof geometry.coordinates !== 'undefined' &&
+ Array.isArray(geometry.coordinates)
+ )
+ },
+
+ isValidGeometryCollection: function (geometryCollection) {
+ return (
+ this.isValidGeoJSON(geometryCollection) &&
+ geometryCollection.type === 'GeometryCollection' &&
+ Array.isArray(geometryCollection.geometries) &&
+ geometryCollection.geometries.every(this.isValidGeometry)
+ )
+ }
+}
+
+export default GeoJSONValidation
diff --git a/src/utils/geojsonValidation.test.js b/src/utils/geojsonValidation.test.js
new file mode 100644
index 00000000..8ed24d97
--- /dev/null
+++ b/src/utils/geojsonValidation.test.js
@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest'
+import GeoJSONValidation from './geojsonValidation'
+
+describe('GeoJSONValidation', () => {
+ describe('validate', () => {
+ it('should return true for valid GeoJSON', () => {
+ const geojson = { type: 'Point', coordinates: [0, 0] }
+ const result = GeoJSONValidation.validate(geojson)
+ expect(result === true)
+ })
+
+ it('should return false for invalid GeoJSON', () => {
+ const geojson = { type: 'InvalidType', coordinates: [0, 0] }
+ const result = GeoJSONValidation.validate(geojson)
+ expect(result === false)
+ })
+ })
+
+ describe('isValidGeoJSON', () => {
+ it('should return true for valid GeoJSON', () => {
+ const geojson = {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ [0, 0]
+ ]
+ ]
+ }
+ const result = GeoJSONValidation.isValidGeoJSON(geojson)
+ expect(result === true)
+ })
+
+ it('should return false for invalid GeoJSON', () => {
+ const geojson = { type: 'InvalidType', coordinates: [0, 0] }
+ const result = GeoJSONValidation.isValidGeoJSON(geojson)
+ expect(result === false)
+ })
+ })
+
+ describe('isValidFeatureCollection', () => {
+ it('should return true for valid FeatureCollection', () => {
+ const featureCollection = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ properties: { name: 'Feature 1' },
+ geometry: { type: 'Point', coordinates: [0, 0] }
+ },
+ {
+ type: 'Feature',
+ properties: { name: 'Feature 2' },
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [0, 0],
+ [1, 1]
+ ]
+ }
+ }
+ ]
+ }
+ const result =
+ GeoJSONValidation.isValidFeatureCollection(featureCollection)
+ expect(result === true)
+ })
+
+ it('should return false for invalid FeatureCollection', () => {
+ const featureCollection = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'InvalidType',
+ properties: { name: 'Feature 1' },
+ geometry: { type: 'Point', coordinates: [0, 0] }
+ }
+ ]
+ }
+ const result =
+ GeoJSONValidation.isValidFeatureCollection(featureCollection)
+ expect(result === false)
+ })
+ })
+
+ describe('isValidFeature', () => {
+ it('should return true for valid Feature', () => {
+ const feature = {
+ type: 'Feature',
+ properties: { name: 'Feature 1' },
+ geometry: { type: 'Point', coordinates: [0, 0] }
+ }
+ const result = GeoJSONValidation.isValidFeature(feature)
+ expect(result === true)
+ })
+
+ it('should return false for invalid Feature', () => {
+ const feature = {
+ type: 'InvalidType',
+ properties: { name: 'Feature 1' },
+ geometry: { type: 'Point', coordinates: [0, 0] }
+ }
+ const result = GeoJSONValidation.isValidFeature(feature)
+ expect(result === false)
+ })
+ })
+
+ describe('isValidGeometry', () => {
+ it('should return true for valid Geometry', () => {
+ const geometry = { type: 'Point', coordinates: [0, 0] }
+ const result = GeoJSONValidation.isValidGeometry(geometry)
+ expect(result === true)
+ })
+
+ it('should return false for invalid Geometry', () => {
+ const geometry = { type: 'InvalidType', coordinates: [0, 0] }
+ const result = GeoJSONValidation.isValidGeometry(geometry)
+ expect(result === false)
+ })
+ })
+
+ describe('isValidGeometryCollection', () => {
+ it('should return true for valid GeometryCollection', () => {
+ const geometryCollection = {
+ type: 'GeometryCollection',
+ geometries: [
+ { type: 'Point', coordinates: [0, 0] },
+ {
+ type: 'LineString',
+ coordinates: [
+ [0, 0],
+ [1, 1]
+ ]
+ }
+ ]
+ }
+ const result =
+ GeoJSONValidation.isValidGeometryCollection(geometryCollection)
+ expect(result === true)
+ })
+
+ it('should return false for invalid GeometryCollection', () => {
+ const geometryCollection = {
+ type: 'GeometryCollection',
+ geometries: [{ type: 'InvalidType', coordinates: [0, 0] }]
+ }
+ const result =
+ GeoJSONValidation.isValidGeometryCollection(geometryCollection)
+ expect(result === false)
+ })
+ })
+})
diff --git a/src/utils/mapHelper.js b/src/utils/mapHelper.js
index cdff5ca0..7f43bb2a 100644
--- a/src/utils/mapHelper.js
+++ b/src/utils/mapHelper.js
@@ -19,6 +19,7 @@ import {
VITE_MOSAIC_TILER_PARAMS
} from '../assets/config'
import { GetMosaicBoundsService } from '../services/get-mosaic-bounds'
+import GeoJSONValidation from './geojsonValidation'
export const footprintLayerStyle = {
color: '#3183f5',
@@ -46,7 +47,24 @@ export const clickedFootprintLayerStyle = {
pane: 'searchResults'
}
-export const customDrawingPolygonStyle = {
+const customSearchPointIconStyle = L.icon({
+ iconSize: [25, 41],
+ iconAnchor: [10, 41],
+ popupAnchor: [2, -40],
+ iconUrl: '/marker-icon.png',
+ shadowUrl: '/marker-shadow.png'
+})
+
+export const customSearchLineStyle = {
+ color: '#00C07B',
+ weight: 2,
+ opacity: 1,
+ dashArray: '4, 4',
+ dashOffset: '0',
+ pane: 'drawPane'
+}
+
+export const customSearchPolygonStyle = {
color: '#00C07B',
weight: 2,
opacity: 1,
@@ -517,7 +535,7 @@ export function enableMapPolyDrawing() {
map.eachLayer(function (layer) {
if (layer.layer_name === 'drawBoundsLayer') {
const drawLayer = e.layer
- drawLayer.setStyle(customDrawingPolygonStyle)
+ drawLayer.setStyle(customSearchPolygonStyle)
drawLayer.options.interactive = false
layer.addLayer(drawLayer)
const data = layer.toGeoJSON()
@@ -535,3 +553,74 @@ export function disableMapPolyDrawing() {
store.getState().mainSlice.mapDrawPolygonHandler.disable()
}
}
+
+export function addUploadedGeojsonToMap(geojson) {
+ const map = store.getState().mainSlice.map
+ if (map && Object.keys(map).length > 0) {
+ clearLayer('drawBoundsLayer')
+ map.eachLayer(function (layer) {
+ if (layer.layer_name === 'drawBoundsLayer') {
+ let geojsonLayer = L.geoJSON(geojson)
+
+ geojsonLayer = L.geoJSON(geojson, {
+ pointToLayer: function (feature, latlng) {
+ return L.marker(latlng, { icon: customSearchPointIconStyle })
+ }
+ })
+ geojsonLayer.setStyle((feature) => {
+ return styleFeatures(feature, geojsonLayer)
+ })
+ geojsonLayer.options.interactive = false
+ layer.addLayer(geojsonLayer)
+ store.dispatch(setsearchGeojsonBoundary(geojson))
+ }
+ })
+ }
+}
+
+export async function parseGeomUpload(geom) {
+ if (GeoJSONValidation.isValidFeatureCollection(geom)) {
+ if (geom.features.length > 1) {
+ throw Error('Only FeatureCollections with a single feature are supported')
+ }
+ return geom.features[0]
+ }
+ if (GeoJSONValidation.isValidFeature(geom)) {
+ return geom
+ }
+ if (GeoJSONValidation.isValidGeometry(geom)) {
+ return {
+ type: 'Feature',
+ geometry: geom,
+ properties: {}
+ }
+ }
+ throw Error('Invalid geojson uploaded')
+}
+
+function styleFeatures(feature, geojsonLayer) {
+ if (
+ feature.geometry.type === 'LineString' ||
+ feature.geometry.type === 'MultiLineString'
+ ) {
+ return customSearchLineStyle
+ }
+ if (
+ feature.geometry.type === 'Polygon' ||
+ feature.geometry.type === 'MultiPolygon'
+ ) {
+ return customSearchPolygonStyle
+ }
+ if (feature.geometry.type === 'GeometryCollection') {
+ const accumulatedStyle = {}
+ feature.geometry.geometries.forEach((part) => {
+ if (part.type === 'LineString' || part.type === 'MultiLineString') {
+ Object.assign(accumulatedStyle, customSearchLineStyle)
+ }
+ if (part.type === 'Polygon' || part.type === 'MultiPolygon') {
+ Object.assign(accumulatedStyle, customSearchPolygonStyle)
+ }
+ })
+ return accumulatedStyle
+ }
+}