From 7faf44980938d29d1f6ba3b366aa0c5bde9c2fc8 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Mon, 25 Mar 2024 14:57:34 -0700 Subject: [PATCH] Issues/swodlr UI final fixes - bug fixed before version 1.0 release (#91) * issues/swodlr-ui-final-fixes: fixed spatial search speed and tutorial back fix * issues/swodlr-ui-final-fixes: fixed spinner, 10 limit, tutorial, map on reload * issues/swodlr-ui-final-fixes: fixed delete bug and added success alert for generation * issues/swodlr-ui-final-fixes: made alert message for search area too large * issues/swodlr-ui-final-fixes: changed search area too large message * issues/swodlr-ui-final-fixes: start search polygon url param * issues/swodlr-ui-final-fixed: removed skip from tutorial, added copy/download tooltips product url * issues/swodlr-ui-final-fixes: added tutorial close confirmation modal * issues/swodlr-ui-final-fixes: added cmr SWOT collection permissions check * issues/swodlr-ui-final-fixes: made cmr permissions alert conditional * issues/swodlr-ui-final-fixes: fixed tutorial back error and no data history tutorial error --------- Co-authored-by: jbyrne --- src/components/about/About.tsx | 34 ++- src/components/app/App.tsx | 41 ++-- src/components/app/appSlice.ts | 13 +- .../edl/AuthorizationCodeHandler.tsx | 40 ++++ .../history/GeneratedProductHistory.tsx | 67 ++++-- src/components/map/WorldMap.tsx | 86 ++++++-- src/components/navbar/PodaacFooter.tsx | 17 +- .../sidebar/CustomizeProductView.tsx | 3 + .../sidebar/CustomizeProductsSidebar.tsx | 3 +- .../sidebar/DeleteGranulesModal.tsx | 7 +- .../sidebar/GenerateProductsModal.tsx | 10 +- .../GranuleSelectionAndConfigurationView.tsx | 18 +- .../sidebar/GranuleSelectionView.tsx | 16 +- src/components/sidebar/GranuleTableAlerts.tsx | 5 +- src/components/sidebar/GranulesTable.tsx | 199 +++++++++++------- .../sidebar/SpatialSearchOptions.tsx | 12 -- src/components/sidebar/actions/modalSlice.ts | 16 +- .../sidebar/actions/productSlice.ts | 10 +- .../InteractiveTutorialModalClose.tsx | 44 ++++ src/components/tutorial/tutorialConstants.ts | 2 +- src/components/welcome/Welcome.tsx | 2 +- src/constants/rasterParameterConstants.ts | 21 +- src/types/constantTypes.ts | 2 +- 23 files changed, 497 insertions(+), 171 deletions(-) create mode 100644 src/components/tutorial/InteractiveTutorialModalClose.tsx diff --git a/src/components/about/About.tsx b/src/components/about/About.tsx index e08c0a2..83c9a26 100644 --- a/src/components/about/About.tsx +++ b/src/components/about/About.tsx @@ -4,9 +4,19 @@ import CompareImage from '../../assets/comparing-images.png' import YukonImage from '../../assets/SWOT-YUKON.jpeg' import LatLongUTM from '../../assets/lat-lon-vs-utm.png' import UpSWOTResolution from '../../assets/swot-go-up-resolution.jpg' +import packageJson from '../../../package.json' +import { useEffect, useState } from "react"; const About = () => { - + const [backendVersion, setBackendVersion] = useState('') + useEffect(() => { + const fetchData = async () => { + setBackendVersion(await fetch('https://swodlr.podaac.sit.earthdatacloud.nasa.gov/api/about').then((version) => version.json()).then(response => response.version)) + } + fetchData() + .catch(console.error); + }, []); + return (

About: SWOT On-Demand Level-2 Raster Generator

@@ -18,7 +28,7 @@ const About = () => { SWODLR is an on-demand raster generation tool that generates customized Surface Water and Ocean Topography (SWOT) Level 2 raster products. SWOT standard products are released in geographically fixed tiles at 100m and 250m resolutions in a Universal Transverse Mercator (UTM) projection grid. SWODLR allows users to generate the same products at different resolutions in either the UTM or geodetic coordinate system (lat/lon). SWODLR also gives an option to change the output granule extent from a nonoverlapping square 128 km x 128 km to an overlapping rectangle 256 km x 128 km to assist with observing areas of interest near the along-track edges of the original square extent.
- Like the standard product, the on-demand product contains rasterized water surface elevation and inundation-extents. This is derived through resampling the upstream pixel cloud (L2_HR_PIXC) and pixel vector (L2_HR_PIXCVEC) datasets onto a uniform grid. A uniform grid is superimposed onto the pixel cloud from the source products, and all pixel-cloud samples within each grid cell are aggregated to produce a single value per raster cell. SWODLR uses the original algorithm that standard SWOT products use to generate products but at a different resolution; it does not just re-grid the standard products. + Like the standard product, the on-demand product contains rasterized water surface elevation and inundation-extents. This is derived through resampling the upstream pixel cloud (L2_HR_PIXC) and pixel vector (L2_HR_PIXCVEC) datasets onto a uniform grid. A uniform grid is superimposed onto the pixel cloud from the source products, and all pixel-cloud samples within each grid cell are aggregated to produce a single value per raster cell. SWODLR uses the original algorithm that standard SWOT products use to generate products but at a different resolution; it does not just re-grid the standard products.
@@ -70,8 +80,6 @@ const About = () => { - {/*

FAQ

*/} -

Definitions

@@ -131,10 +139,24 @@ const About = () => {
-

Version History

+

Current Version

-
Version 1 (9/05/2023)
+ +
+ SWODLR UI: + {packageJson.version} +
+
+
{`(Release Notes)`}
+
+ + +
+ SWODLR API: + {backendVersion} +
+
diff --git a/src/components/app/App.tsx b/src/components/app/App.tsx index eaf6719..d8984af 100644 --- a/src/components/app/App.tsx +++ b/src/components/app/App.tsx @@ -14,10 +14,12 @@ import { Session } from '../../authentication/session'; import { getCurrentUser, setStartTutorial } from './appSlice'; import { useEffect, useState } from 'react'; import GranuleSelectionAndConfigurationView from '../sidebar/GranuleSelectionAndConfigurationView'; -import Joyride from 'react-joyride'; +import Joyride, { ACTIONS, EVENTS } from 'react-joyride'; import { deleteProduct } from '../sidebar/actions/productSlice'; import { tutorialSteps } from '../tutorial/tutorialConstants'; import InteractiveTutorialModal from '../tutorial/InteractiveTutorialModal'; +import { setShowCloseTutorialTrue, setSkipTutorialTrue } from '../sidebar/actions/modalSlice'; +import InteractiveTutorialModalClose from '../tutorial/InteractiveTutorialModalClose'; const App = () => { const dispatch = useAppDispatch() @@ -33,33 +35,46 @@ const App = () => { const [joyride, setState] = useState({ run: startTutorial, - steps: tutorialSteps + steps: tutorialSteps, + stepIndex: 0 }) + useEffect(() => { - setState({...joyride, run: startTutorial }) + setState({...joyride, run: startTutorial, stepIndex: 0}) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [startTutorial]); const handleJoyrideCallback = (data: { action: any; index: any; status: any; type: any; step: any; lifecycle: any; }) => { - const { action, step, type, lifecycle } = data; + const { action, step, type, lifecycle, index } = data; const stepTarget = step.target - if (stepTarget === '#configure-options-breadcrumb' && action === 'update') { + + if ([EVENTS.STEP_AFTER, EVENTS.TARGET_NOT_FOUND].includes(type)) { + // Update state to advance the tour + setState({...joyride, stepIndex: index + (action === ACTIONS.PREV ? -1 : 1) }); + } + + if (action === 'close') { + dispatch(setShowCloseTutorialTrue()) + } else if (stepTarget === '#configure-options-breadcrumb' && action === 'update') { navigate(`/customizeProduct/configureOptions${search}`) } else if (stepTarget === '#configure-options-breadcrumb' && action === 'prev' && lifecycle === 'complete') { navigate(`/customizeProduct/selectScenes${search}`) - } else if (stepTarget === '#my-data-page' && action === 'prev') { + } + else if (stepTarget === '#my-data-page' && action === 'prev' && lifecycle === 'complete') { navigate(`/customizeProduct/configureOptions${search}`) - } else if (stepTarget === '#added-scenes' && action === 'update') { - navigate(`/customizeProduct/selectScenes?cyclePassScene=1_413_120&showUTMAdvancedOptions=true`) + } + else if (stepTarget === '#added-scenes' && action === 'update') { + navigate(`/customizeProduct/selectScenes?cyclePassScene=9_515_130&showUTMAdvancedOptions=true`) } else if (stepTarget === '#customization-tab' && action === 'start') { navigate('/customizeProduct/selectScenes') - } else if ((stepTarget === '#generate-products-button' && action === 'close' && lifecycle === 'complete') || (stepTarget === '#my-data-page' && action === 'next')) { + } else if (action === 'next' && stepTarget === '#my-data-page') { navigate(`/generatedProductHistory${search}`) } else if (type === 'tour:end') { - dispatch(deleteProduct(addedProducts.map(product => product.granuleId))) + dispatch(setSkipTutorialTrue()) dispatch(setStartTutorial(false)) + dispatch(deleteProduct(addedProducts.map(product => product.granuleId))) navigate(`/customizeProduct/selectScenes`) } - // TODO: Make condition to load previous page when clicking previous before trying to target component to highlight. Use conditions "stepTarget === '#alert-messages' && action === 'prev' && lifecycle === 'init'" }; useEffect(() => { @@ -103,9 +118,8 @@ const App = () => { callback={(data) => handleJoyrideCallback(data)} run={joyride.run} steps={joyride.steps} + stepIndex={joyride.stepIndex} showProgress - showSkipButton - hideCloseButton continuous scrollToFirstStep /> @@ -119,6 +133,7 @@ const App = () => { , true)}/> +
); } diff --git a/src/components/app/appSlice.ts b/src/components/app/appSlice.ts index 2194e64..624b694 100644 --- a/src/components/app/appSlice.ts +++ b/src/components/app/appSlice.ts @@ -1,5 +1,5 @@ import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit' -import { PageTypes, UserData } from '../../types/constantTypes' +import { PageTypes } from '../../types/constantTypes' import { CurrentUserData } from '../../types/graphqlTypes' import { Session } from '../../authentication/session'; import { getUserData } from '../../user/userData'; @@ -9,7 +9,8 @@ interface AppState { userAuthenticated: boolean, currentPage: PageTypes, currentUser: CurrentUserData | null, - startTutorial: boolean + startTutorial: boolean, + userHasCorrectEdlPermissions: boolean } export const getCurrentUser = createAsyncThunk('currentUser', async () => { @@ -21,7 +22,8 @@ const initialState: AppState = { userAuthenticated: false, currentPage: 'welcome', currentUser: null, - startTutorial: false + startTutorial: false, + userHasCorrectEdlPermissions: true } export const appSlice = createSlice({ @@ -37,6 +39,9 @@ export const appSlice = createSlice({ setStartTutorial: (state, action: PayloadAction) => { state.startTutorial = action.payload }, + setUserHasCorrectEdlPermissions: (state, action: PayloadAction) => { + state.userHasCorrectEdlPermissions = action.payload + }, }, extraReducers(builder) { builder.addCase(getCurrentUser.fulfilled, (state, action) => { @@ -55,6 +60,6 @@ export const appSlice = createSlice({ }, }); -export const { logoutCurrentUser, setStartTutorial } = appSlice.actions +export const { logoutCurrentUser, setStartTutorial, setUserHasCorrectEdlPermissions } = appSlice.actions export default appSlice.reducer diff --git a/src/components/edl/AuthorizationCodeHandler.tsx b/src/components/edl/AuthorizationCodeHandler.tsx index 5ada8e8..4d9be22 100644 --- a/src/components/edl/AuthorizationCodeHandler.tsx +++ b/src/components/edl/AuthorizationCodeHandler.tsx @@ -5,6 +5,46 @@ import { exchangeAuthenticationCode } from "../../authentication/edl"; import { OAuthTokenExchangeFailed } from "../../authentication/exception"; import { Session } from "../../authentication/session"; +import { spatialSearchCollectionConceptId, spatialSearchResultLimit } from "../../constants/rasterParameterConstants"; + +export const checkUseHasCorrectEdlPermissions = async () => { + try { + // get session token to use in spatial search query + const session = await Session.getCurrent(); + if (session === null) { + throw new Error('No current session'); + } + const authToken = await session.getAccessToken(); + if (authToken === null) { + throw new Error('Failed to get authentication token'); + } + + const polygonUrlString = '&polygon[]=-49.921875,68.58850924263909,-50.06469726562501,68.56844733448305,-50.06469726562501,68.52223694881727,-49.91638183593751,68.52424806853186,-49.921875,68.58850924263909' + const spatialSearchUrl = `https://cmr.earthdata.nasa.gov/search/granules?collection_concept_id=${spatialSearchCollectionConceptId}${polygonUrlString}&page_size=${spatialSearchResultLimit}` + const userHasCorrectEdlPermissions = await fetch(spatialSearchUrl, { + method: 'GET', + credentials: 'omit', + headers: { + Authorization: `Bearer ${authToken}` + } + }).then(response => response.text()).then(data => { + const parser = new DOMParser(); + const xml = parser.parseFromString(data, "application/xml"); + const userHasCorrectEdlPermissions = parseInt(xml.getElementsByTagName("hits")[0].textContent ?? '0') > 0 + return userHasCorrectEdlPermissions + }) + return userHasCorrectEdlPermissions + } catch (err) { + if (err instanceof Error) { + // return err + return false + } else { + // return 'something happened' + return false + } + } +} + export default function AuthorizationCodeHandler(): ReactElement { const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); diff --git a/src/components/history/GeneratedProductHistory.tsx b/src/components/history/GeneratedProductHistory.tsx index 8dfbee0..474a0b3 100644 --- a/src/components/history/GeneratedProductHistory.tsx +++ b/src/components/history/GeneratedProductHistory.tsx @@ -1,4 +1,4 @@ -import { Alert, Col, OverlayTrigger, Row, Table, Tooltip, Button } from "react-bootstrap"; +import { Alert, Col, OverlayTrigger, Row, Table, Tooltip, Button, Spinner } from "react-bootstrap"; import { useAppSelector } from "../../redux/hooks"; import { getUserProductsResponse, Product } from "../../types/graphqlTypes"; import { useEffect, useState } from "react"; @@ -12,10 +12,14 @@ const GeneratedProductHistory = () => { const { search } = useLocation(); const navigate = useNavigate() const [userProducts, setUserProducts] = useState([]) + const [waitingForProductsToLoad, setWaitingForProductsToLoad] = useState(true) useEffect(() => { const fetchData = async () => { - const userProductsResponse: getUserProductsResponse = await getUserProducts() + const userProductsResponse: getUserProductsResponse = await getUserProducts().then((response) => { + setWaitingForProductsToLoad(false) + return response + }) if (userProductsResponse.status === 'success') setUserProducts(userProductsResponse.products as Product[]) } fetchData() @@ -42,6 +46,32 @@ const GeneratedProductHistory = () => { ) + + const renderCopyDownloadButton = (downloadUrlString: string) => ( + + Copy + + } + > + + + ) + + const renderDownloadButton = (downloadUrlString: string) => ( + + Download + + } + > + + + ) const renderColTitle = (labelEntry: string[], index: number) => { let infoIcon = infoIconsToRender.includes(labelEntry[0]) ? renderInfoIcon(labelEntry[0]) : null @@ -78,10 +108,10 @@ const GeneratedProductHistory = () => { if (entry[0] === 'downloadUrl' && entry[1] !== 'N/A') { const downloadUrlString = granules[0].uri cellContents = - + {entry[1]} - - + {(renderCopyDownloadButton(downloadUrlString))} + {renderDownloadButton(downloadUrlString)} } else { cellContents = entry[1] @@ -103,20 +133,33 @@ const GeneratedProductHistory = () => { return navigate(`/generatedProductHistory${search}`)} style={{cursor: 'pointer'}}>{alertMessage} } + const waitingForProductsToLoadSpinner = () => { + return ( +
+
Loading Data Table...
+ + Loading... + +
+ ) + } + const renderProductHistoryViews = () => { return ( - -

Generated Products Data

- {renderHistoryTable()} - {userProducts.length === 0 ? {productHistoryAlert()} : null} + + {renderHistoryTable()} + {userProducts.length === 0 ? {productHistoryAlert()} : null} ) } return ( - - {renderProductHistoryViews()} - + <> +

Generated Products Data

+ + {waitingForProductsToLoad ? waitingForProductsToLoadSpinner() : renderProductHistoryViews()} + + ); } diff --git a/src/components/map/WorldMap.tsx b/src/components/map/WorldMap.tsx index 0ae56e4..75b413f 100644 --- a/src/components/map/WorldMap.tsx +++ b/src/components/map/WorldMap.tsx @@ -12,7 +12,8 @@ import booleanClockwise from '@turf/boolean-clockwise'; import { afterCPSL, afterCPSR, beforeCPS, spatialSearchCollectionConceptId, spatialSearchResultLimit } from '../../constants/rasterParameterConstants'; import { addSpatialSearchResults, setMapFocus, setWaitingForSpatialSearch } from '../sidebar/actions/productSlice'; import { SpatialSearchResult } from '../../types/constantTypes'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { useEffect } from 'react'; let DefaultIcon = L.icon({ iconUrl: icon, @@ -20,29 +21,63 @@ let DefaultIcon = L.icon({ }); L.Marker.prototype.options.icon = DefaultIcon; -function UpdateMapCenter() { +const UpdateMapCenter = () => { const dispatch = useAppDispatch() const mapFocus = useAppSelector((state) => state.product.mapFocus) + // search parameters + const [searchParams, setSearchParams] = useSearchParams() + + // put the current center and zoom into the url parameters + const handleMapFocus = (center: number[], zoom: number) => { + const currentSearchParams = Object.fromEntries(searchParams.entries()) + currentSearchParams.center = `${center[0]},${center[1]}` + currentSearchParams.zoom = String(zoom) + setSearchParams(currentSearchParams) + dispatch(setMapFocus({center, zoom})) + } const map = useMapEvent('moveend', () => { const center = [map.getCenter().lat, map.getCenter().lng] const zoom = map.getZoom() - if ((mapFocus.center[0] !== center[0] && mapFocus.center[1] !== center[1]) || mapFocus.zoom !== zoom) dispatch(setMapFocus({center, zoom})) + if ((mapFocus.center[0] !== center[0] && mapFocus.center[1] !== center[1]) || mapFocus.zoom !== zoom) handleMapFocus(center, zoom) }) return null } +const ChangeView = () => { + const mapFocus = useAppSelector((state) => state.product.mapFocus) + const map = useMap() + map.setView(mapFocus.center as LatLngExpression, mapFocus.zoom) + return null +} + const WorldMap = () => { const addedProducts = useAppSelector((state) => state.product.addedProducts) const mapFocus = useAppSelector((state) => state.product.mapFocus) + const userHasCorrectEdlPermissions = useAppSelector((state) => state.app.userHasCorrectEdlPermissions) const dispatch = useAppDispatch() const footprintStyleOptions = { color: 'limegreen' } + // search parameters + const [searchParams, setSearchParams] = useSearchParams() - const ChangeView = () => { - const map = useMap() - map.setView(mapFocus.center as LatLngExpression, mapFocus.zoom) - return null - } + useEffect(() => { + // if center and zoom are in url params, set the current center to them + const center = searchParams.get('center') + const zoom = searchParams.get('zoom') + if (center && zoom) { + const centerParamSplit = center.split(',') + const centerToUse: number[] = [parseFloat(centerParamSplit[0]), parseFloat(centerParamSplit[1])] + const zoomToUse = parseInt(zoom) + if (centerToUse !== mapFocus.center || zoomToUse !== mapFocus.zoom) { + dispatch(setMapFocus({center: centerToUse, zoom: zoomToUse})) + } + } + + // TODO: implement search polygon search param + // const searchPolygon = searchParams.get('searchPolygon') + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const getScenesWithinCoordinates = async (coordinatesToSearch: {lat: number, lng: number}[][]) => { try { @@ -55,7 +90,6 @@ const WorldMap = () => { if (authToken === null) { throw new Error('Failed to get authentication token'); } - dispatch(setWaitingForSpatialSearch(true)) const polygonUrlString = coordinatesToSearch.map((polygon) => { @@ -84,9 +118,12 @@ const WorldMap = () => { headers: { Authorization: `Bearer ${authToken}` } - }).then(response => response.text()).then(data => { + }).then(async data => { + const responseText = await data.text() + // TODO: make subsequent calls to get granules in spatial search area till everything is found. + // current issue is that 1000 (2000 total divided by 2) is limited by the cmr api. const parser = new DOMParser(); - const xml = parser.parseFromString(data, "application/xml"); + const xml = parser.parseFromString(responseText, "application/xml"); const references: SpatialSearchResult[] = Array.from(new Set(Array.from(xml.getElementsByTagName("name")).map(nameElement => { return (nameElement.textContent)?.match(`${beforeCPS}([0-9]+(_[0-9]+)+)(${afterCPSR}|${afterCPSL})`)?.[1] }))).map(foundIdString => { @@ -98,9 +135,7 @@ const WorldMap = () => { return references }) dispatch(addSpatialSearchResults(spatialSearchResponse as SpatialSearchResult[])) - dispatch(setWaitingForSpatialSearch(false)) } catch (err) { - dispatch(setWaitingForSpatialSearch(false)) if (err instanceof Error) { return err } else { @@ -110,9 +145,17 @@ const WorldMap = () => { } const onCreate = async (createEvent: any) => { - await getScenesWithinCoordinates([createEvent.layer.getLatLngs()[0]]) - // set the new map focus location to what it was when polygon created so it will stay the same after map reload - dispatch(setMapFocus({center: [createEvent.layer._renderer._center.lat, createEvent.layer._renderer._center.lng], zoom: createEvent.target._zoom})) + const searchPolygonLatLngs = createEvent.layer.getLatLngs()[0] + + // TODO: implement search polygon search param + // const currentSearchParams = Object.fromEntries(searchParams.entries()) + // const searchPolygonLatLngsString = searchPolygonLatLngs.map((latLngObject: {lat: number, lng: number}) => `${latLngObject.lat},${latLngObject.lng}`).join('_') + // currentSearchParams.searchPolygon = searchPolygonLatLngsString + // setSearchParams(currentSearchParams) + + await getScenesWithinCoordinates([searchPolygonLatLngs]) + // set the new map focus location to what it was when polygon created so it will stay the same after map reload + dispatch(setMapFocus({center: [createEvent.layer._renderer._center.lat, createEvent.layer._renderer._center.lng], zoom: createEvent.target._zoom})) } const onEdit = async (editEvent: any) => { @@ -128,7 +171,7 @@ const WorldMap = () => { id='spatial-search-map' zoom={7} scrollWheelZoom={true} zoomControl={false} > - {useLocation().pathname.includes('selectScenes') ? ( + {(useLocation().pathname.includes('selectScenes') && userHasCorrectEdlPermissions) ? ( { url='https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' attribution='Esri, Maxar, Earthstar Geographics, and the GIS User Community' maxZoom = {18} + noWrap + bounds={ + [ + [-89.9999, -179.9999], + [89.9999, 179.9999] + ] + } /> {addedProducts.map((productObject, index) => ( - {[
{`Cycle: ${productObject.cycle}`}
,
{`Pass: ${productObject.pass}`}
,
{`Scene: ${productObject.scene}`}
]}
+ {[
{`Cycle: ${productObject.cycle}`}
,
{`Pass: ${productObject.pass}`}
,
{`Scene: ${productObject.scene}`}
]}
))} diff --git a/src/components/navbar/PodaacFooter.tsx b/src/components/navbar/PodaacFooter.tsx index 3a4725e..5ae3e14 100644 --- a/src/components/navbar/PodaacFooter.tsx +++ b/src/components/navbar/PodaacFooter.tsx @@ -2,9 +2,11 @@ import Navbar from 'react-bootstrap/Navbar'; import { useAppSelector } from '../../redux/hooks' import { Col, Row } from 'react-bootstrap'; import { useLocation, useNavigate } from 'react-router-dom'; +import packageJson from '../../../package.json' const PodaacFooter = () => { const colorModeClass = useAppSelector((state) => state.navbar.colorModeClass) + const userAuthenticated = useAppSelector((state) => state.app.userAuthenticated) const navigate = useNavigate(); const { search } = useLocation(); @@ -12,7 +14,7 @@ const PodaacFooter = () => { - Version 1.0 Pre-Alpha of SWOT On-Demand Level-2 Raster Generator (SWODLR) + {`Version ${packageJson.version} Beta of SWOT On-Demand Level-2 Raster Generator (SWODLR)`} @@ -21,11 +23,14 @@ const PodaacFooter = () => { Privacy - - navigate(`/about${search}`)}> - About SWODLR - - + { userAuthenticated ? + ( + navigate(`/about${search}`)}> + About SWODLR + + ) + : null + } window.open('mailto:podaac@podaac.jpl.nasa.gov')}> Contact diff --git a/src/components/sidebar/CustomizeProductView.tsx b/src/components/sidebar/CustomizeProductView.tsx index bc47460..3eba245 100644 --- a/src/components/sidebar/CustomizeProductView.tsx +++ b/src/components/sidebar/CustomizeProductView.tsx @@ -1,6 +1,7 @@ import GranuleTable from './GranulesTable'; import ProductCustomization from './ProductCustomization'; import GenerateProducts from './GenerateProducts'; +import GranuleTableAlerts from './GranuleTableAlerts'; const GranuleSelectionView = () => { return ( @@ -9,6 +10,8 @@ const GranuleSelectionView = () => {
+ + ); } diff --git a/src/components/sidebar/CustomizeProductsSidebar.tsx b/src/components/sidebar/CustomizeProductsSidebar.tsx index ee0eff9..c88379e 100644 --- a/src/components/sidebar/CustomizeProductsSidebar.tsx +++ b/src/components/sidebar/CustomizeProductsSidebar.tsx @@ -47,6 +47,7 @@ const CustomizeProductsSidebar = (props: CustomizeProductSidebarProps) => { setLocalSidebarWidth(sidebarWidthNumber) setSidebarWidth(sidebarWidthNumber) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [resizeEndLocation]) return ( @@ -57,8 +58,6 @@ const CustomizeProductsSidebar = (props: CustomizeProductSidebarProps) => { {renderSidebarContents()} - - {/* TODO: uncomment when granule footprints are being retrieved to display on map */}
handleResizeClickDown(event)}> handleResizeClickDown(event)}/>
diff --git a/src/components/sidebar/DeleteGranulesModal.tsx b/src/components/sidebar/DeleteGranulesModal.tsx index 2ed6d47..1f1b10c 100644 --- a/src/components/sidebar/DeleteGranulesModal.tsx +++ b/src/components/sidebar/DeleteGranulesModal.tsx @@ -15,7 +15,12 @@ const GenerateProductsModal = () => { const removeCPSFromUrl = (cpsCombosToRemove: string[]) => { const cyclePassSceneParameters = searchParams.get('cyclePassScene')?.split('-') if (cyclePassSceneParameters) { - const cyclePassSceneParametersToKeep = cyclePassSceneParameters.filter(cpsCombo => !cpsCombosToRemove.includes(cpsCombo)).join('-') + const cyclePassSceneParametersToKeep = cyclePassSceneParameters.filter(cpsCombo => { + const urlCPSSplit = cpsCombo.split('_') + const reconstructedUrlCPS = `${urlCPSSplit[0]}_${urlCPSSplit[1]}_${urlCPSSplit[2]}` + const keepCPSCombo = !cpsCombosToRemove.includes(reconstructedUrlCPS) + return keepCPSCombo + }).join('-') const currentUrlParameters = Object.fromEntries(searchParams.entries()) if (cyclePassSceneParametersToKeep.length === 0) { const {cyclePassScene, ...restOfCurrentUrlParameters} = currentUrlParameters diff --git a/src/components/sidebar/GenerateProductsModal.tsx b/src/components/sidebar/GenerateProductsModal.tsx index de92f11..aa83945 100644 --- a/src/components/sidebar/GenerateProductsModal.tsx +++ b/src/components/sidebar/GenerateProductsModal.tsx @@ -2,18 +2,26 @@ import Button from 'react-bootstrap/Button'; import Modal from 'react-bootstrap/Modal'; import { useAppSelector, useAppDispatch } from '../../redux/hooks' import { setShowGenerateProductModalFalse } from './actions/modalSlice' -import { addGeneratedProducts } from './actions/productSlice' +import { addGeneratedProducts, addGranuleTableAlerts } from './actions/productSlice' import { Row } from 'react-bootstrap'; +import { granuleAlertMessageConstant } from '../../constants/rasterParameterConstants'; +import { alertMessageInput } from '../../types/constantTypes'; const GenerateProductsModal = () => { const showGenerateProductModal = useAppSelector((state) => state.modal.showGenerateProductModal) const addedGranules = useAppSelector((state) => state.product.addedProducts) const dispatch = useAppDispatch() + const setSaveGranulesAlert = (alert: alertMessageInput, additionalParameters?: any[]) => { + const {message, variant} = granuleAlertMessageConstant[alert] + dispatch(addGranuleTableAlerts({type: alert, message, variant, tableType: 'productCustomization' })) + } + const handleGenerate = () => { // unselect select-all box dispatch(addGeneratedProducts(addedGranules.map(granuleObj => granuleObj.granuleId))) dispatch(setShowGenerateProductModalFalse()) + setSaveGranulesAlert('successfullyGenerated') } return ( diff --git a/src/components/sidebar/GranuleSelectionAndConfigurationView.tsx b/src/components/sidebar/GranuleSelectionAndConfigurationView.tsx index e8b766b..17bac93 100644 --- a/src/components/sidebar/GranuleSelectionAndConfigurationView.tsx +++ b/src/components/sidebar/GranuleSelectionAndConfigurationView.tsx @@ -4,18 +4,32 @@ import WorldMap from '../map/WorldMap' import { setShowTutorialModalTrue, setSkipTutorialTrue } from './actions/modalSlice'; import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; +import { checkUseHasCorrectEdlPermissions } from '../edl/AuthorizationCodeHandler'; +import { setUserHasCorrectEdlPermissions } from '../app/appSlice'; const GranuleSelectionAndConfigurationView = (props: GranuleSelectionAndConfigurationViewProps) => { const dispatch = useAppDispatch() const skipTutorial = useAppSelector((state) => state.modal.skipTutorial) + const userAuthenticated = useAppSelector((state) => state.app.userAuthenticated) const {mode} = props useEffect(() => { - if (!skipTutorial) { + const fetchData = async () => { + const userHasCorrectEdlPermissions = await checkUseHasCorrectEdlPermissions() + dispatch(setUserHasCorrectEdlPermissions(userHasCorrectEdlPermissions)) + } + + // call the function + fetchData() + }, []) + + useEffect(() => { + if (!skipTutorial && userAuthenticated) { dispatch(setShowTutorialModalTrue()) dispatch(setSkipTutorialTrue()) } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userAuthenticated]) return ( <> diff --git a/src/components/sidebar/GranuleSelectionView.tsx b/src/components/sidebar/GranuleSelectionView.tsx index c88d913..bd1f46d 100644 --- a/src/components/sidebar/GranuleSelectionView.tsx +++ b/src/components/sidebar/GranuleSelectionView.tsx @@ -1,13 +1,27 @@ +import { Alert, Col, Row } from 'react-bootstrap'; import GranuleTable from './GranulesTable'; import GranuleTableAlerts from './GranuleTableAlerts'; import SpatialSearchOptions from './SpatialSearchOptions'; +import { useAppSelector } from '../../redux/hooks'; const GranuleSelectionView = () => { + const userHasCorrectEdlPermissions = useAppSelector((state) => state.app.userHasCorrectEdlPermissions) return (
- + + { + userHasCorrectEdlPermissions ? + null : +
+ + + The SWOT dataset is not public yet. Until then, some functionality of this site will be limited. You are not yet able to add scenes, configure scenes, or generate products. + + +
+ }
); } diff --git a/src/components/sidebar/GranuleTableAlerts.tsx b/src/components/sidebar/GranuleTableAlerts.tsx index f832aa7..48bfe99 100644 --- a/src/components/sidebar/GranuleTableAlerts.tsx +++ b/src/components/sidebar/GranuleTableAlerts.tsx @@ -1,13 +1,14 @@ import { useAppSelector } from '../../redux/hooks' import { Alert, Col, Row } from 'react-bootstrap'; import DeleteGranulesModal from './DeleteGranulesModal'; +import { TableTypes } from '../../types/constantTypes'; -const GranuleTableAlerts = () => { +const GranuleTableAlerts: React.FC<{tableType: TableTypes}> = ({tableType}) => { const granuleTableAlerts = useAppSelector((state) => state.product.granuleTableAlerts) return (
- {granuleTableAlerts.map((alertObject, index) => ( + {granuleTableAlerts.filter(item => item.tableType === tableType).map((alertObject, index) => ( {alertObject.message} diff --git a/src/components/sidebar/GranulesTable.tsx b/src/components/sidebar/GranulesTable.tsx index 4bb8342..3e42b76 100644 --- a/src/components/sidebar/GranulesTable.tsx +++ b/src/components/sidebar/GranulesTable.tsx @@ -6,7 +6,7 @@ import { granuleAlertMessageConstant, granuleSelectionLabels, productCustomizati import { Button, Col, Form, OverlayTrigger, Row, Tooltip, Spinner } from 'react-bootstrap'; import { InfoCircle, Plus, Trash } from 'react-bootstrap-icons'; import { AdjustType, AdjustValueDecoder, GranuleForTable, GranuleTableProps, InputType, SaveType, SpatialSearchResult, TableTypes, alertMessageInput, allProductParameters, handleSaveResult, validScene } from '../../types/constantTypes'; -import { addProduct, setSelectedGranules, setGranuleFocus, addGranuleTableAlerts, editProduct, addSpatialSearchResults, setWaitingForFootprintSearch, clearGranuleTableAlerts } from './actions/productSlice'; +import { addProduct, setSelectedGranules, setGranuleFocus, addGranuleTableAlerts, editProduct, addSpatialSearchResults, clearGranuleTableAlerts, setWaitingForSpatialSearch } from './actions/productSlice'; import { setShowDeleteProductModalTrue } from './actions/modalSlice'; import DeleteGranulesModal from './DeleteGranulesModal'; import { graphQLClient } from '../../user/userData'; @@ -23,9 +23,10 @@ const GranuleTable = (props: GranuleTableProps) => { const generateProductParameters = useAppSelector((state) => state.product.generateProductParameters) const showUTMAdvancedOptions = useAppSelector((state) => state.product.showUTMAdvancedOptions) const waitingForSpatialSearch = useAppSelector((state) => state.product.waitingForSpatialSearch) - const waitingForFootprintSearch = useAppSelector((state) => state.product.waitingForFootprintSearch) const spatialSearchStartDate = useAppSelector((state) => state.product.spatialSearchStartDate) const spatialSearchEndDate = useAppSelector((state) => state.product.spatialSearchEndDate) + const startTutorial = useAppSelector((state) => state.app.startTutorial) + const userHasCorrectEdlPermissions = useAppSelector((state) => state.app.userHasCorrectEdlPermissions) const dispatch = useAppDispatch() @@ -37,7 +38,6 @@ const GranuleTable = (props: GranuleTableProps) => { // set the default url state parameters useEffect(() => { - dispatch(clearGranuleTableAlerts()) // if any cycle scene and pass parameters in url, add them to table const cyclePassSceneParameters = searchParams.get('cyclePassScene') if (cyclePassSceneParameters) { @@ -47,33 +47,93 @@ const GranuleTable = (props: GranuleTableProps) => { handleSave('urlParameter', sceneParamArray.length, index, splitSceneParams[0], splitSceneParams[1], splitSceneParams[2]) }) } - }, [tableType === 'granuleSelection' ? null : addedProducts]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableType === 'granuleSelection' ? null : addedProducts, startTutorial ? searchParams : null]) + const validateSceneAvailability = async (cycleToUse: number, passToUse: number, sceneToUse: number[], cpsList?: {cycle: string, pass: string, scene: string}[]): Promise => { + try { + // build graphql availableScene query with all cycle/pass/scene combos requested + let queryAliasString = `` + // if there is a list of cycle pass and scenes go through them (spatial search) and if not, use first 3 function params (manual search) + if (cpsList) { + for(const specificCPS of cpsList) { + const {cycle, pass, scene} = specificCPS + const comboId = `${cycle}_${pass}_${scene}` + queryAliasString += ` s_${comboId}: availableScene(cycle: ${cycle}, pass: ${pass}, scene: ${scene}) ` + } + } else { + for(const specificScene of sceneToUse) { + const comboId = `${cycleToUse}_${passToUse}_${specificScene}` + queryAliasString += ` s_${comboId}: availableScene(cycle: ${cycleToUse}, pass: ${passToUse}, scene: ${specificScene}) ` + } + } + const queryAliasObject = `{${queryAliasString}}` + const res: {availableScene: boolean} = await graphQLClient.request(queryAliasObject).then(response => { + const responseToReturn = Object.fromEntries(Object.entries(response as {availableScene: boolean}).map(responseObj => [responseObj[0].replace('s_', ''), responseObj[1]])) + return responseToReturn as {availableScene: boolean} + }) + return res + + } catch (err) { + console.log (err) + return {} + } + } + + // Spatial search use effect useEffect(() => { dispatch(clearGranuleTableAlerts()) if (spatialSearchResults.length > 0) { let scenesFoundArray: string[] = [] let addedScenes: string[] = [] + const fetchData = async () => { - for(let i=0; i { - if(result.savedScenes) { - addedScenes.push(...(result.savedScenes).map(productObject => productObject.granuleId)) + if (spatialSearchResults.length < 1000) { + // check validity before saving + const validationResult = await validateSceneAvailability(0,0,[0],spatialSearchResults).then(result => Object.entries(result).filter(resultEntry => resultEntry[1]).map(valuePair => { + const cpsSplit = valuePair[0].split('_') + return {cycle: cpsSplit[0], pass: cpsSplit[1], scene: cpsSplit[2]} + })) + + if (validationResult.length > 0) { + for(let i=0; i result === 'found something').length) >= granuleTableLimit) { + // don't let more than 10 be added + scenesFoundArray.push('hit granule limit') + } else { + await handleSave('spatialSearch', validationResult.length, i, validationResult[i].cycle, validationResult[i].pass, validationResult[i].scene).then(result => { + if(result.savedScenes) { + addedScenes.push(...(result.savedScenes).map(productObject => productObject.granuleId)) + } + scenesFoundArray.push(result.result) + }) + } + } - scenesFoundArray.push(result.result) - }) + if(addedScenes.length > 0) { + // add parameters + addSearchParamToCurrentUrlState({'cyclePassScene': addedScenes.join('-')}) + } + } else { + scenesFoundArray.push('noScenesFound') + } + } else { + // If too many spatial search results, the search doesn't work because there too many granules and a limit was reached. + // In this scenario, make an alert that indicates that the search area was too large. + // TODO: remove this alert when there is a fix implemented for cmr spatial search limit. + // The valid granules are sometimes not a part of the first 1000 results which is the bug here. + scenesFoundArray.push('spatialSearchAreaTooLarge') } - // add parameters - addSearchParamToCurrentUrlState({'cyclePassScene': addedScenes.join('-')}) + dispatch(setWaitingForSpatialSearch(false)) return scenesFoundArray } // call the function fetchData() .then((noScenesFoundResults) => { - if(noScenesFoundResults.includes('noScenesFound') && !noScenesFoundResults.includes('found something')){ - setSaveGranulesAlert('noScenesFound') - } + if(noScenesFoundResults.includes('noScenesFound') && !noScenesFoundResults.includes('found something')) setSaveGranulesAlert('noScenesFound') + if(noScenesFoundResults.includes('hit granule limit')) setSaveGranulesAlert('granuleLimit') + if(noScenesFoundResults.includes('spatialSearchAreaTooLarge')) setSaveGranulesAlert('spatialSearchAreaTooLarge') }) // make sure to catch any error .catch(console.error); @@ -81,6 +141,7 @@ const GranuleTable = (props: GranuleTableProps) => { // clear spatial results out of redux after use if(spatialSearchResults.length !== 0) dispatch(addSpatialSearchResults([] as SpatialSearchResult[])) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [spatialSearchResults]) const addSearchParamToCurrentUrlState = (newPairsObject: object, remove?: string) => { @@ -136,26 +197,7 @@ const GranuleTable = (props: GranuleTableProps) => { const [scene, setScene] = useState(''); const allAddedGranules = addedProducts.map(parameterObject => parameterObject.granuleId) const [waitingForScenesToBeAdded, setWaitingForScenesToBeAdded] = useState(false) - -const validateSceneAvailability = async (cycleToUse: number, passToUse: number, sceneToUse: number[]): Promise => { - try { - // build graphql availableScene query with all cycle/pass/scene combos requested - let queryAliasString = `` - for(const specificScene of sceneToUse) { - const comboId = `${cycleToUse}_${passToUse}_${specificScene}` - queryAliasString += ` s_${comboId}: availableScene(cycle: ${cycleToUse}, pass: ${passToUse}, scene: ${specificScene}) ` - } - const queryAliasObject = `{${queryAliasString}}` - const res: {availableScene: boolean} = await graphQLClient.request(queryAliasObject, {cycle: cycleToUse, pass: passToUse, scene: sceneToUse[0]}).then(response => { - const responseToReturn = Object.fromEntries(Object.entries(response as {availableScene: boolean}).map(responseObj => [responseObj[0].replace('s_', ''), responseObj[1]])) - return responseToReturn as {availableScene: boolean} - }) - return res - } catch (err) { - console.log (err) - return {} - } -} + const [waitingForFootprintSearch, setWaitingForFootprintSearch] = useState(false) const getScenesArray = (sceneString: string): string[] => { const scenesArray = [] @@ -172,7 +214,7 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, return scenesArray } - const setSaveGranulesAlert = (alert: alertMessageInput) => { + const setSaveGranulesAlert = (alert: alertMessageInput, additionalParameters?: any[]) => { const {message, variant} = granuleAlertMessageConstant[alert] dispatch(addGranuleTableAlerts({type: alert, message, variant, tableType: 'granuleSelection' })) } @@ -242,7 +284,7 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, if (authToken === null) { throw new Error('Failed to get authentication token'); } - dispatch(setWaitingForFootprintSearch(true)) + setWaitingForFootprintSearch(true) // convert the tileId in the cps string to a sceneId (divide by 2) const granuleIdTileToSceneArray = granuleId.split('_') let sceneString = String(parseInt(granuleIdTileToSceneArray[2])/2).padStart(3, '0'); @@ -274,10 +316,10 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, return [[], true] } }) - dispatch(setWaitingForFootprintSearch(false)) + setWaitingForFootprintSearch(false) return footprintResult } catch (err) { - dispatch(setWaitingForFootprintSearch(false)) + setWaitingForFootprintSearch(false) console.log (err) if (err instanceof Error) { return err @@ -316,14 +358,13 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, if (!validPass) setSaveGranulesAlert('invalidPass') if (!validScene) setSaveGranulesAlert('invalidScene') return {result: 'first step'} - } else if (addedProducts.length >= granuleTableLimit) { - setSaveGranulesAlert('granuleLimit') - return {result: 'second-step'} - } else { + } + else { const granulesToAdd: allProductParameters[] = [] let someGranulesAlreadyAdded = false let cyclePassSceneSearchParams = searchParams.get('cyclePassScene') ? String(searchParams.get('cyclePassScene')) : '' const sceneArray = getScenesArray(sceneToUse) + let validScenesThatCouldNotBeAdded: string[] = [] // check scenes availability const validationResult = await validateSceneAvailability(parseInt(cycleToUse), parseInt(passToUse), sceneArray.map(sceneId => parseInt(sceneId))).then(scenesAvailable => { // return response @@ -337,31 +378,36 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, }) // TODO: make alert more verbose if some granules are added and others are not when adding more than one with scene hyphen sceneArray.filter(sceneNumber => scenesAvailable[`${cycleToUse}_${passToUse}_${sceneNumber}`]).forEach(async sceneId => { - // check if granule exists with that scene, cycle, and pass - const comboAlreadyAdded = alreadyAddedCyclePassScene(cycleToUse, passToUse, sceneId) - const cyclePassSceneInBounds = checkInBounds('cycle', cycleToUse) && checkInBounds('pass', passToUse) && checkInBounds('scene', sceneId) - if (cyclePassSceneInBounds && !comboAlreadyAdded) { - // get the granuleId from it and pass it to the parameters - const parameters: allProductParameters = { - granuleId: `${cycleToUse}_${passToUse}_${sceneId}`, - name: '', - cycle: cycleToUse, - pass: passToUse, - scene: sceneId, - outputGranuleExtentFlag: parameterOptionValues.outputGranuleExtentFlag.default as number, - outputSamplingGridType: parameterOptionValues.outputSamplingGridType.default as string, - rasterResolution: parameterOptionValues.rasterResolutionUTM.default as number, - utmZoneAdjust: parameterOptionValues.utmZoneAdjust.default as string, - mgrsBandAdjust: parameterOptionValues.mgrsBandAdjust.default as string, - footprint: sampleFootprint - } - // add cycle/pass/scene to url parameters - if (!searchParamSceneComboAlreadyInUrl(cyclePassSceneSearchParams, cycleToUse, passToUse, sceneId)) { - cyclePassSceneSearchParams += `${cyclePassSceneSearchParams.length === 0 ? '' : '-'}${cycleToUse}_${passToUse}_${sceneId}` + if ((granulesToAdd.length + addedProducts.length) >= granuleTableLimit) { + validScenesThatCouldNotBeAdded.push(sceneId) + setSaveGranulesAlert('granuleLimit') + } else { + // check if granule exists with that scene, cycle, and pass + const comboAlreadyAdded = alreadyAddedCyclePassScene(cycleToUse, passToUse, sceneId) + const cyclePassSceneInBounds = checkInBounds('cycle', cycleToUse) && checkInBounds('pass', passToUse) && checkInBounds('scene', sceneId) + if (cyclePassSceneInBounds && !comboAlreadyAdded) { + // get the granuleId from it and pass it to the parameters + const parameters: allProductParameters = { + granuleId: `${cycleToUse}_${passToUse}_${sceneId}`, + name: '', + cycle: cycleToUse, + pass: passToUse, + scene: sceneId, + outputGranuleExtentFlag: parameterOptionValues.outputGranuleExtentFlag.default as number, + outputSamplingGridType: parameterOptionValues.outputSamplingGridType.default as string, + rasterResolution: parameterOptionValues.rasterResolutionUTM.default as number, + utmZoneAdjust: parameterOptionValues.utmZoneAdjust.default as string, + mgrsBandAdjust: parameterOptionValues.mgrsBandAdjust.default as string, + footprint: sampleFootprint + } + // add cycle/pass/scene to url parameters + if (!searchParamSceneComboAlreadyInUrl(cyclePassSceneSearchParams, cycleToUse, passToUse, sceneId)) { + cyclePassSceneSearchParams += `${cyclePassSceneSearchParams.length === 0 ? '' : '-'}${cycleToUse}_${passToUse}_${sceneId}` + } + granulesToAdd.push(parameters) + } else if (comboAlreadyAdded) { + someGranulesAlreadyAdded = true } - granulesToAdd.push(parameters) - } else if (comboAlreadyAdded) { - someGranulesAlreadyAdded = true } }) if (saveType !== 'spatialSearch' && saveType !== 'urlParameter') { @@ -394,8 +440,12 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, // don't run time range check if granule was manually entered if (saveType === 'manual' || saveType === 'urlParameter') { addSearchParamToCurrentUrlState({'cyclePassScene': cyclePassSceneSearchParams}) - if (saveType !== 'urlParameter') { - setSaveGranulesAlert('success') + if (saveType !== 'urlParameter' || startTutorial) { + if (validScenesThatCouldNotBeAdded.length > 0) { + setSaveGranulesAlert('someSuccess') + } else { + setSaveGranulesAlert('success') + } } dispatch(addProduct(productsWithFootprints)) } else { @@ -668,7 +718,9 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number, - setCycle(event.target.value)}/> + { + setCycle(event.target.value) + }}/> setPass(event.target.value)}/> setScene(event.target.value)}/> @@ -685,20 +737,23 @@ const validateSceneAvailability = async (cycleToUse: number, passToUse: number,
- {tableType === 'granuleSelection' ? To add multiple scenes at once, enter two numbers into the scene input field separated by a hyphen (e.g. 1-10) : null} {tableType === 'granuleSelection' ? ( + <> + To add multiple scenes at once, enter two numbers into the scene input field separated by a hyphen (e.g. 1-10) {waitingForScenesToBeAdded || waitingForSpatialSearch || waitingForFootprintSearch ? Loading... : - } + {`${addedProducts.length}/${granuleTableLimit} scenes added`} {renderInfoIcon('granuleTableLimit')} + ) : null } diff --git a/src/components/sidebar/SpatialSearchOptions.tsx b/src/components/sidebar/SpatialSearchOptions.tsx index 34394af..8a1c091 100644 --- a/src/components/sidebar/SpatialSearchOptions.tsx +++ b/src/components/sidebar/SpatialSearchOptions.tsx @@ -81,20 +81,8 @@ const SpatialSearchOptions = () => { />
- {/* - - - - */} - {/* -

- Draw areas to search spatially on the map by using the controls on the top right -

-
*/}

Draw areas to search spatially on the map by using the controls on the top right. The scene search will start once you finish drawing the search area shape.

diff --git a/src/components/sidebar/actions/modalSlice.ts b/src/components/sidebar/actions/modalSlice.ts index ae3a743..63d2f17 100644 --- a/src/components/sidebar/actions/modalSlice.ts +++ b/src/components/sidebar/actions/modalSlice.ts @@ -10,6 +10,7 @@ interface AddCustomProductModalState { selectedGranules: string[], showTutorialModal: boolean, skipTutorial: boolean, + showCloseTutorialModal: boolean } // Define the initial state using that type @@ -19,11 +20,12 @@ const initialState: AddCustomProductModalState = { showDeleteProductModal: false, showGenerateProductModal: false, showTutorialModal: false, - skipTutorial: true, + skipTutorial: false, // allProducts: this will be like a 'database' for the local state of all the products added // the key will be cycleId_passId_sceneId and the value will be a 'parameterOptionDefaults' type object sampleGranuleDataArray: [], - selectedGranules: [] + selectedGranules: [], + showCloseTutorialModal: false } export const modalSlice = createSlice({ @@ -70,6 +72,12 @@ export const modalSlice = createSlice({ }, setSkipTutorialTrue: (state) => { state.skipTutorial = true + }, + setShowCloseTutorialFalse: (state) => { + state.showCloseTutorialModal = false + }, + setShowCloseTutorialTrue: (state) => { + state.showCloseTutorialModal = true } }, }) @@ -87,7 +95,9 @@ export const { setShowTutorialModalFalse, setShowTutorialModalTrue, setSkipTutorialFalse, - setSkipTutorialTrue + setSkipTutorialTrue, + setShowCloseTutorialFalse, + setShowCloseTutorialTrue, } = modalSlice.actions export default modalSlice.reducer \ No newline at end of file diff --git a/src/components/sidebar/actions/productSlice.ts b/src/components/sidebar/actions/productSlice.ts index a98c05e..ce4ea8e 100644 --- a/src/components/sidebar/actions/productSlice.ts +++ b/src/components/sidebar/actions/productSlice.ts @@ -18,7 +18,6 @@ interface GranuleState { showUTMAdvancedOptions: boolean, spatialSearchResults: SpatialSearchResult[], waitingForSpatialSearch: boolean, - waitingForFootprintSearch: boolean, spatialSearchStartDate: string, spatialSearchEndDate: string, mapFocus: MapFocusObject @@ -26,8 +25,6 @@ interface GranuleState { const {name, cycle, pass, scene, ...generateProductParametersFiltered } = parameterOptionDefaults -const date = new Date() - // Define the initial state using that type const initialState: GranuleState = { // allProducts: this will be like a 'database' for the local state of all the products added @@ -36,7 +33,7 @@ const initialState: GranuleState = { sampleGranuleDataArray: [], selectedGranules: [], granuleFocus: [33.854457, -118.709093], - mapFocus: {center: [33.854457, -118.709093], zoom: 7}, + mapFocus: {center: [33.854457, -118.709093], zoom: 6}, generatedProducts: [], generateProductParameters: generateProductParametersFiltered, granuleTableAlerts: [], @@ -44,7 +41,6 @@ const initialState: GranuleState = { showUTMAdvancedOptions: false, spatialSearchResults: [], waitingForSpatialSearch: false, - waitingForFootprintSearch: false, spatialSearchStartDate: (new Date(2022, 11, 16)).toISOString(), spatialSearchEndDate: (new Date()).toISOString() } @@ -137,9 +133,6 @@ export const productSlice = createSlice({ setWaitingForSpatialSearch: (state, action: PayloadAction) => { state.waitingForSpatialSearch = action.payload }, - setWaitingForFootprintSearch: (state, action: PayloadAction) => { - state.waitingForFootprintSearch = action.payload - }, setSpatialSearchStartDate: (state, action: PayloadAction) => { state.spatialSearchStartDate = action.payload }, @@ -162,7 +155,6 @@ export const { setShowUTMAdvancedOptions, addSpatialSearchResults, setWaitingForSpatialSearch, - setWaitingForFootprintSearch, setSpatialSearchStartDate, setSpatialSearchEndDate, setMapFocus, diff --git a/src/components/tutorial/InteractiveTutorialModalClose.tsx b/src/components/tutorial/InteractiveTutorialModalClose.tsx new file mode 100644 index 0000000..3c7a7c8 --- /dev/null +++ b/src/components/tutorial/InteractiveTutorialModalClose.tsx @@ -0,0 +1,44 @@ +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { useAppSelector, useAppDispatch } from '../../redux/hooks' +import { setShowCloseTutorialFalse, setSkipTutorialTrue } from '../sidebar/actions/modalSlice' +import { Row } from 'react-bootstrap'; +import { setStartTutorial } from '../app/appSlice'; +import { deleteProduct } from '../sidebar/actions/productSlice'; +import { useNavigate } from 'react-router-dom'; + +const InteractiveTutorialModalClose = () => { + const showCloseTutorialModal = useAppSelector((state) => state.modal.showCloseTutorialModal) + const dispatch = useAppDispatch() + const navigate = useNavigate() + const addedProducts = useAppSelector((state) => state.product.addedProducts) + + const handleCloseTutorial = () => { + dispatch(setSkipTutorialTrue()) + dispatch(setStartTutorial(false)) + dispatch(deleteProduct(addedProducts.map(product => product.granuleId))) + navigate(`/customizeProduct/selectScenes`) + dispatch(setShowCloseTutorialFalse()) + } + + return ( + dispatch(setShowCloseTutorialFalse())}> + + Exit Interactive Tutorial + + + + +
Are you sure you want to exit the tutorial?
+
+
+ + + + + +
+ ); +} + +export default InteractiveTutorialModalClose; \ No newline at end of file diff --git a/src/components/tutorial/tutorialConstants.ts b/src/components/tutorial/tutorialConstants.ts index 41c93e5..0647899 100644 --- a/src/components/tutorial/tutorialConstants.ts +++ b/src/components/tutorial/tutorialConstants.ts @@ -138,7 +138,7 @@ export const tutorialSteps = [ }, { target: "#scenes-to-customize", - content: "This is this table showing the scenes you can customize. Also shown are the options which are scene specific which are not applied to all the scenes in the list.", + content: "This is the table showing the scenes you can customize. Also shown are the options which are scene specific which are not applied to all the scenes in the list.", disableBeacon: true, styles: { options: { diff --git a/src/components/welcome/Welcome.tsx b/src/components/welcome/Welcome.tsx index 75eefc5..908e1ec 100644 --- a/src/components/welcome/Welcome.tsx +++ b/src/components/welcome/Welcome.tsx @@ -29,7 +29,7 @@ const Welcome = () => { return ( -

SWOT Level-2 On-demand Raster Generator

+

SWOT On-Demand Level-2 Raster Generator

diff --git a/src/constants/rasterParameterConstants.ts b/src/constants/rasterParameterConstants.ts index fad2e10..322db77 100644 --- a/src/constants/rasterParameterConstants.ts +++ b/src/constants/rasterParameterConstants.ts @@ -95,6 +95,8 @@ export const parameterOptionDefaults = { mgrsBandAdjust: '0', } +export const granuleTableLimit = 10 + export const parameterHelp: ParameterHelp = { outputGranuleExtentFlag: `There are two sizing options for raster granules: nonoverlapping square (128 km x 128 km) or overlapping rectangular (256 km x 128 km). The rectangular granule extent is 64 km longer in along-track on both sides of the granule and can be useful for observing areas of interest near the along-track edges of the nonoverlapping granules without the need to stitch sequential granules together.`, outputSamplingGridType: `Specifies the type of the raster sampling grid. It can be either a Universal Transverse Mercator (UTM) grid or a geodetic latitude-longitude grid.`, @@ -104,7 +106,8 @@ export const parameterHelp: ParameterHelp = { cycle: `The repeat orbit cycle number of the observation. SWOT’s orbit is 21 days and thus observations in the same 21-day orbit period would have the same cycle number.`, pass: `Predefined sections of the orbit between the maximum and minimum latitudes. SWOT has 584 passes in one cycle, split into ascending and descending passes`, scene: `Predefined 128 x 128 km squares of the SWOT observations.`, - status: `The processing status of your custom product. The status types are as follows: NEW, UNAVAILABLE, GENERATING, ERROR, READY, AVAILABLE` + status: `The processing status of your custom product. The status types are as follows: NEW, UNAVAILABLE, GENERATING, ERROR, READY, AVAILABLE`, + granuleTableLimit: `There is a limit of ${granuleTableLimit} scenes allowed to be added to the scene table at a time. This is to ensure our scene processing pipeline can handle the demand of all of SWODLR's users.` } export interface InputBounds { @@ -129,8 +132,6 @@ scene: { } } -export const granuleTableLimit = 10 - export const granuleAlertMessageConstant: granuleAlertMessageConstantType = { success: { message: 'Successfully added scenes!', @@ -177,12 +178,24 @@ export const granuleAlertMessageConstant: granuleAlertMessageConstantType = { variant: 'danger', }, granuleLimit: { - message: `You can only process ${granuleTableLimit} scenes at a time.`, + message: `You can only process ${granuleTableLimit} scenes at a time so some scenes could not be added.`, variant: 'danger' }, notInTimeRange: { message: `Some scenes were not within the specified spatial search time range.`, variant: 'danger' + }, + someSuccess: { + message: `Successfully added some scenes.`, + variant: 'success' + }, + successfullyGenerated: { + message: `Successfully started product generation! Go to the 'My Data' page to track progress.`, + variant: 'success' + }, + spatialSearchAreaTooLarge: { + message: `The search area you've selected on the map is too large. Please choose a smaller area to search.`, + variant: 'warning' } } diff --git a/src/types/constantTypes.ts b/src/types/constantTypes.ts index b61f34a..65cefa4 100644 --- a/src/types/constantTypes.ts +++ b/src/types/constantTypes.ts @@ -137,7 +137,7 @@ export interface validScene { [key: string]: boolean } -export type alertMessageInput = 'success' | 'alreadyAdded' | 'allScenesNotAvailable' | 'alreadyAddedAndNotFound' | 'noScenesAdded' | 'readyForGeneration' | 'invalidCycle' | 'invalidPass' | 'invalidScene' | 'invalidScene' | 'someScenesNotAvailable' | 'granuleLimit' | 'notInTimeRange' | 'noScenesFound' +export type alertMessageInput = 'success' | 'alreadyAdded' | 'allScenesNotAvailable' | 'alreadyAddedAndNotFound' | 'noScenesAdded' | 'readyForGeneration' | 'invalidCycle' | 'invalidPass' | 'invalidScene' | 'invalidScene' | 'someScenesNotAvailable' | 'granuleLimit' | 'notInTimeRange' | 'noScenesFound' | 'someSuccess' | 'successfullyGenerated' | 'spatialSearchAreaTooLarge' export interface SpatialSearchResult { cycle: string,