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 + } +}