diff --git a/frontend/e2e/pages/map.ts b/frontend/e2e/pages/map.ts index 2757c57b..b816d495 100644 --- a/frontend/e2e/pages/map.ts +++ b/frontend/e2e/pages/map.ts @@ -18,7 +18,9 @@ export const map_page = async (page: Page) => { await expect( page.getByRole('button', { name: 'Polygon Search' }), ).toBeVisible() - await expect(page.getByRole('button', { name: 'Point Search' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Radius Search' }), + ).toBeVisible() const searchBy = page.getByText('Status') await expect(searchBy).toBeVisible() diff --git a/frontend/src/components/AuthorizationListItem.tsx b/frontend/src/components/AuthorizationListItem.tsx index 16c3afc9..c5e37c52 100644 --- a/frontend/src/components/AuthorizationListItem.tsx +++ b/frontend/src/components/AuthorizationListItem.tsx @@ -46,7 +46,7 @@ export function AuthorizationListItem({ sx={sx} > - Authorization #: {number} + Authorization #: {number} { state.searchResults = [] }, + setRadiusActive: (state, action: PayloadAction) => { + state.isRadiusActive = action.payload + if (action.payload) { + state.bottomDrawerHeight = 500 // 300 + 200 + } else { + state.bottomDrawerHeight = 300 + } + }, }, }) @@ -114,6 +124,7 @@ export const { toggleActiveTool, clearActiveTool, clearSearchResults, + setRadiusActive, } = mapSlice.actions // Create a thunk to clear both search results and search text @@ -162,3 +173,6 @@ export const useHasDataLayersOn = () => useSelector(selectDataLayers).length > 0 const selectActiveTool = (state: RootState) => state.map.activeTool export const useActiveTool = () => useSelector(selectActiveTool) + +export const useRadiusActive = () => + useSelector((state: RootState) => state.map.isRadiusActive) diff --git a/frontend/src/features/omrr/omrr-slice.ts b/frontend/src/features/omrr/omrr-slice.ts index f6f8d8ba..2754ec91 100644 --- a/frontend/src/features/omrr/omrr-slice.ts +++ b/frontend/src/features/omrr/omrr-slice.ts @@ -52,6 +52,7 @@ export interface OmrrSliceState { lastSearchTime?: number pointFilterCenter?: LatLngTuple pointFilterRadius: number + pointFilterFinished: boolean polygonFilterPositions: LatLngTuple[] polygonFilterFinished: boolean } @@ -79,6 +80,7 @@ export const initialState: OmrrSliceState = { // The point filter is active when the center point is defined pointFilterCenter: undefined, pointFilterRadius: MIN_CIRCLE_RADIUS, + pointFilterFinished: false, // The polygon filter is active when finished is true polygonFilterPositions: [], polygonFilterFinished: false, @@ -215,8 +217,8 @@ export const omrrSlice = createSlice({ } }, resetPointFilter: (state) => { - // Reset point filter and search if necessary state.pointFilterRadius = MIN_CIRCLE_RADIUS + state.pointFilterFinished = false if (state.pointFilterCenter) { state.pointFilterCenter = undefined performSearch(state, false) @@ -232,6 +234,18 @@ export const omrrSlice = createSlice({ performSearch(state, false) } }, + setPointFilterFinished: (state) => { + if (!state.pointFilterFinished && state.pointFilterCenter) { + state.pointFilterFinished = true + performSearch(state) + } + }, + setPointFilterUnfinished: (state) => { + if (!state.pointFilterFinished && state.pointFilterCenter) { + state.pointFilterFinished = false + performSearch(state) + } + }, }, extraReducers: (builder: ActionReducerMapBuilder) => { // Handle the pending action @@ -284,6 +298,8 @@ export const { setPointFilterRadius, resetPointFilter, clearShapeFilters, + setPointFilterFinished, + setPointFilterUnfinished, } = omrrSlice.actions export default omrrSlice.reducer @@ -356,3 +372,13 @@ const selectPointFilterRadius = (state: RootState) => state.omrr.pointFilterRadius export const usePointFilterCenter = () => useSelector(selectPointFilterCenter) export const usePointFilterRadius = () => useSelector(selectPointFilterRadius) + +// Add a new selector to check if point filter is active +export const usePointFilterActive = () => { + return useSelector( + (state: RootState) => state.omrr.pointFilterCenter !== undefined, + ) +} + +export const usePointFilterFinished = () => + useSelector((state: RootState) => state.omrr.pointFilterFinished) diff --git a/frontend/src/pages/map/MapView.test.tsx b/frontend/src/pages/map/MapView.test.tsx index eae5b1e2..5313bcc9 100644 --- a/frontend/src/pages/map/MapView.test.tsx +++ b/frontend/src/pages/map/MapView.test.tsx @@ -128,7 +128,7 @@ describe('Test suite for MapView', () => { it('should render the MapView and test point search', async () => { const { user } = renderComponent(themeBreakpointValues.xxl, mockOmrrData) - const pointSearchBtn = screen.getByRole('button', { name: 'Point Search' }) + const pointSearchBtn = screen.getByRole('button', { name: 'Radius Search' }) await user.click(pointSearchBtn) const cancelBtn = screen.getByRole('button', { name: 'Cancel' }) diff --git a/frontend/src/pages/map/drawer/MapBottomDrawer.css b/frontend/src/pages/map/drawer/MapBottomDrawer.css index 41ee70df..832cd6ce 100644 --- a/frontend/src/pages/map/drawer/MapBottomDrawer.css +++ b/frontend/src/pages/map/drawer/MapBottomDrawer.css @@ -12,6 +12,7 @@ border-radius: 6px 6px 0 0; border-bottom: 1px solid var(--surface-color-border-light); transition: height 0.5s ease-in-out; + max-height: calc(100% - var(--header-height)); } .map-bottom-drawer.map-bottom-drawer--full-height { @@ -85,3 +86,11 @@ button.map-bottom-drawer-button { overflow: hidden auto; padding-bottom: 40px; } + +.map-bottom-drawer--expanded { + min-height: 100px; +} + +.map-bottom-drawer--expanded.radius-active { + min-height: 250px; +} diff --git a/frontend/src/pages/map/drawer/MapBottomDrawer.tsx b/frontend/src/pages/map/drawer/MapBottomDrawer.tsx index 82865758..4a167c19 100644 --- a/frontend/src/pages/map/drawer/MapBottomDrawer.tsx +++ b/frontend/src/pages/map/drawer/MapBottomDrawer.tsx @@ -15,6 +15,7 @@ import { SearchResultsList } from './SearchResultsList' import { PolygonSearch } from '../search/PolygonSearch' import { PointSearch } from '../search/PointSearch' import { ClearSelectedItemButton } from './ClearSelectedItemButton' +import { useRadiusActive } from '@/features/map/map-slice' import ChevronUpIcon from '@/assets/svgs/fa-chevron-up.svg?react' import CloseIcon from '@/assets/svgs/close.svg?react' @@ -31,7 +32,7 @@ function getTitle( case ActiveToolEnum.polygonSearch: return 'Polygon Search' case ActiveToolEnum.pointSearch: - return 'Point Search' + return 'Radius Search' case ActiveToolEnum.searchBy: return 'Status' case ActiveToolEnum.filterBy: @@ -64,6 +65,7 @@ export function MapBottomDrawer() { } = useBottomDrawerState() const [fullHeight, setFullHeight] = useState(false) const ref = useRef(null) + const isRadiusActive = useRadiusActive() const onClose = () => { setExpanded(false) @@ -96,11 +98,16 @@ export function MapBottomDrawer() { return (
diff --git a/frontend/src/pages/map/drawer/MapDrawer.test.tsx b/frontend/src/pages/map/drawer/MapDrawer.test.tsx index bf600f43..ee7d78a4 100644 --- a/frontend/src/pages/map/drawer/MapDrawer.test.tsx +++ b/frontend/src/pages/map/drawer/MapDrawer.test.tsx @@ -228,7 +228,7 @@ describe('Test suite for MapDrawer', () => { await waitFor(() => expect(div).toHaveClass('map-bottom-drawer--expanded')) expect(state.bottomDrawerHeight).toBe(MAP_BOTTOM_DRAWER_HEIGHT_SMALL) expect(state.activeTool).toBe(ActiveToolEnum.pointSearch) - screen.getByText('Point Search') + screen.getByText('Radius Search') screen.getByRole('slider', { name: 'Search radius' }) // Swipe up to make full height diff --git a/frontend/src/pages/map/layers/PointSearchLayer.tsx b/frontend/src/pages/map/layers/PointSearchLayer.tsx index bc7abc18..80b2c7ca 100644 --- a/frontend/src/pages/map/layers/PointSearchLayer.tsx +++ b/frontend/src/pages/map/layers/PointSearchLayer.tsx @@ -8,6 +8,7 @@ import { setPointFilterCenter, usePointFilterCenter, usePointFilterRadius, + usePointFilterFinished, } from '@/features/omrr/omrr-slice' import { useMapCrosshairsCursor } from '../hooks/useMapCrosshairsCursor' import { CrosshairsTooltipMarker } from './CrosshairsTooltipMarker' @@ -24,22 +25,27 @@ function CircleLayer() { const dispatch = useDispatch() const center = usePointFilterCenter() const radius = usePointFilterRadius() + const finished = usePointFilterFinished() const map = useMap() useMapCrosshairsCursor(map) useMapEvents({ click: (ev: LeafletMouseEvent) => { - const newCenter: LatLngTuple = [ev.latlng.lat, ev.latlng.lng] - dispatch(setPointFilterCenter(newCenter)) + if (!finished) { + const newCenter: LatLngTuple = [ev.latlng.lat, ev.latlng.lng] + dispatch(setPointFilterCenter(newCenter)) + } }, }) const drawCircle = center && radius > 0 return ( <> - - Click to place center point - + {!finished && ( + + Click to place center point + + )} {drawCircle && ( )} diff --git a/frontend/src/pages/map/search/AutocompleteItem.tsx b/frontend/src/pages/map/search/AutocompleteItem.tsx index f07de78b..ea3ca08f 100644 --- a/frontend/src/pages/map/search/AutocompleteItem.tsx +++ b/frontend/src/pages/map/search/AutocompleteItem.tsx @@ -41,7 +41,7 @@ function getLabel(matchType: MatchType, item: OmrrData | undefined): ReactNode { const { 'Authorization Number': number = 0 } = item return ( - Authorization #: {number} + Authorization #: {number} ) } diff --git a/frontend/src/pages/map/search/MapSearch.test.tsx b/frontend/src/pages/map/search/MapSearch.test.tsx index 8cde9240..33a089cd 100644 --- a/frontend/src/pages/map/search/MapSearch.test.tsx +++ b/frontend/src/pages/map/search/MapSearch.test.tsx @@ -14,7 +14,7 @@ describe('Test suite for MapSearch', () => { screen.getByRole('button', { name: 'Find Me' }) screen.getByRole('button', { name: 'Layers' }) screen.getByRole('button', { name: 'Polygon Search' }) - screen.getByRole('button', { name: 'Point Search' }) + screen.getByRole('button', { name: 'Radius Search' }) screen.getByText('Status') screen.getByRole('button', { name: 'Filter' }) @@ -37,7 +37,7 @@ describe('Test suite for MapSearch', () => { screen.queryByRole('button', { name: 'Layers' }), ).not.toBeInTheDocument() screen.getByRole('button', { name: 'Polygon Search' }) - screen.getByRole('button', { name: 'Point Search' }) + screen.getByRole('button', { name: 'Radius Search' }) screen.getByText('Status') screen.getByRole('button', { name: 'Filter' }) }) diff --git a/frontend/src/pages/map/search/PointSearch.test.tsx b/frontend/src/pages/map/search/PointSearch.test.tsx index 31e05283..c4431f83 100644 --- a/frontend/src/pages/map/search/PointSearch.test.tsx +++ b/frontend/src/pages/map/search/PointSearch.test.tsx @@ -55,7 +55,7 @@ describe('Test suite for PointSearch', () => { const setRadiusBtn = screen.getByRole('button', { name: 'Set Radius' }) await user.click(setRadiusBtn) - screen.getByText(`${MIN_CIRCLE_RADIUS} m`) + screen.getByText(`${MIN_CIRCLE_RADIUS / 1000} km`) const slider = screen.getByRole('slider', { name: 'Search radius' }) fireEvent.change(slider, { target: { value: 1000 } }) expect(state.pointFilterCenter).toBeUndefined() @@ -68,7 +68,7 @@ describe('Test suite for PointSearch', () => { expect(screen.queryByText('Cancel')).not.toBeInTheDocument() screen.getByText('Set Radius:') const slider = screen.getByRole('slider', { name: 'Search radius' }) - screen.getByText(`${MIN_CIRCLE_RADIUS} m`) + screen.getByText(`${MIN_CIRCLE_RADIUS / 1000} km`) fireEvent.change(slider, { target: { value: 2000 } }) expect(state.pointFilterCenter).toBeUndefined() expect(state.pointFilterRadius).toBe(2000) diff --git a/frontend/src/pages/map/search/PointSearch.tsx b/frontend/src/pages/map/search/PointSearch.tsx index a065eda6..b47b4c2a 100644 --- a/frontend/src/pages/map/search/PointSearch.tsx +++ b/frontend/src/pages/map/search/PointSearch.tsx @@ -1,31 +1,132 @@ import { useDispatch } from 'react-redux' -import { Button, Slider, Typography } from '@mui/material' +import { Button, Slider, Typography, TextField } from '@mui/material' import clsx from 'clsx' +import { useEffect } from 'react' import DropdownButton from '@/components/DropdownButton' import { MIN_CIRCLE_RADIUS } from '@/constants/constants' -import { clearActiveTool } from '@/features/map/map-slice' +import { + clearActiveTool, + toggleActiveTool, + useActiveTool, + setRadiusActive, +} from '@/features/map/map-slice' import { resetPointFilter, setPointFilterRadius, usePointFilterRadius, + usePointFilterActive, + usePointFilterFinished, + setPointFilterFinished, + setPointFilterUnfinished, } from '@/features/omrr/omrr-slice' import { formatDistance } from '@/utils/utils' +import { ActiveToolEnum } from '@/constants/constants' import CloseIcon from '@/assets/svgs/fa-close.svg?react' +import CheckIcon from '@/assets/svgs/fa-check.svg?react' interface Props { isSmall?: boolean className?: string + showControls?: boolean } -export function PointSearch({ isSmall = false, className }: Readonly) { +const styles = { + container: { + width: '100%', + display: 'flex', + justifyContent: 'center', + }, + pointSearchContent: { + width: '100%', + maxWidth: '800px', + padding: '0 20px', + }, + sliderContainer: ({ isSmall }: { isSmall: boolean }) => ({ + display: 'flex', + flexDirection: isSmall ? ('column' as const) : ('row' as const), + gap: '20px', + alignItems: 'center', + }), + controlsRow: { + display: 'flex', + alignItems: 'center', + gap: '20px', + width: '100%', + justifyContent: 'space-between', + }, + controlsRight: { + display: 'flex', + alignItems: 'center', + gap: '20px', + }, + desktopContainer: { + display: 'flex', + alignItems: 'center', + gap: '20px', + }, + sliderWrapper: { + width: '300px', + margin: '0 auto', + }, + labelContainer: { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, + textField: { + marginTop: '-10px', + '& input[type=number]': { + MozAppearance: 'textfield', + }, + '& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button': + { + WebkitAppearance: 'none', + }, + }, + okButton: { marginTop: '-10px' }, + input: { + textAlign: 'right', + paddingRight: '2px', + paddingLeft: '2px', + paddingTop: '2px', + paddingBottom: '2px', + }, +} as const + +export function PointSearch({ + isSmall = false, + className, + showControls = true, +}: Readonly) { const dispatch = useDispatch() const radius = usePointFilterRadius() + const activeTool = useActiveTool() + const isDrawing = activeTool === ActiveToolEnum.pointSearch + const isFilterActive = usePointFilterActive() + const finished = usePointFilterFinished() + + useEffect(() => { + dispatch(setRadiusActive(true)) + return () => { + dispatch(setRadiusActive(false)) + } + }, [dispatch]) const onCancel = () => { dispatch(resetPointFilter()) dispatch(clearActiveTool()) + dispatch(setPointFilterUnfinished()) + dispatch(setRadiusActive(false)) + } + + const onFinish = () => { + const mapContainer = document.querySelector('.map-container') + if (mapContainer) { + mapContainer.classList.remove('crosshairs-cursor') + } + dispatch(setPointFilterFinished()) } const onRadiusChange = (_ev: any, value: number | number[]) => { @@ -36,57 +137,162 @@ export function PointSearch({ isSmall = false, className }: Readonly) { dispatch(setPointFilterRadius(newRadius)) } + const onInputChange = (event: React.ChangeEvent) => { + const value = + event.target.value === '' ? 1 : parseInt(event.target.value, 10) + if (!isNaN(value)) { + const newRadius = Math.min( + Math.max(value * 1000, MIN_CIRCLE_RADIUS), + 400000, + ) + dispatch(setPointFilterRadius(newRadius)) + } + } + const sliderBox = (
- {isSmall && ( - - Set Radius: - + {isSmall ? ( +
+
+ + Set Radius: + +
+ km, + inputProps: { + min: 1, + max: 400, + inputMode: 'numeric', + style: styles.input, + }, + }} + /> + +
+
+
+ +
+ 1 km + 400 km +
+
+
+ ) : ( +
+
+ +
+ 1 km + 400 km +
+
+ km, + inputProps: { + min: 1, + max: 400, + inputMode: 'numeric', + style: styles.input, + }, + }} + /> +
)} - - - {formatDistance(radius, 1)} -
) return isSmall ? ( - sliderBox +
+
{sliderBox}
+
) : (
- - - Set Radius - + {showControls && ( + <> + + + Set Radius + + + + )}
) } diff --git a/frontend/src/pages/map/search/PointSearchButton.tsx b/frontend/src/pages/map/search/PointSearchButton.tsx index f691add6..f9507535 100644 --- a/frontend/src/pages/map/search/PointSearchButton.tsx +++ b/frontend/src/pages/map/search/PointSearchButton.tsx @@ -6,6 +6,9 @@ import { ActiveToolEnum } from '@/constants/constants' import { resetPointFilter, resetPolygonFilter, + usePointFilterActive, + setPointFilterUnfinished, + usePointFilterFinished, } from '@/features/omrr/omrr-slice' import { toggleActiveTool } from '@/features/map/map-slice' @@ -17,15 +20,17 @@ interface Props { export function PointSearchButton({ isActive }: Readonly) { const dispatch = useDispatch() + const isFilterActive = usePointFilterActive() + const isFinished = usePointFilterFinished() const onClick = () => { - if (isActive) { - // Turn off point search - dispatch(resetPointFilter()) - } else { - // starting point search - make sure the polygon filter is turned off + dispatch(resetPointFilter()) + dispatch(setPointFilterUnfinished()) + + if (!isActive) { dispatch(resetPolygonFilter()) } + dispatch(toggleActiveTool(ActiveToolEnum.pointSearch)) } @@ -42,10 +47,26 @@ export function PointSearchButton({ isActive }: Readonly) { )} onClick={onClick} startIcon={ - +
+ + {isFilterActive && ( +
+ )} +
} > - Point Search + Radius Search ) } diff --git a/frontend/src/pages/map/search/PolygonSearchButton.tsx b/frontend/src/pages/map/search/PolygonSearchButton.tsx index 59110654..ceb435be 100644 --- a/frontend/src/pages/map/search/PolygonSearchButton.tsx +++ b/frontend/src/pages/map/search/PolygonSearchButton.tsx @@ -7,6 +7,7 @@ import { toggleActiveTool } from '@/features/map/map-slice' import { resetPointFilter, resetPolygonFilter, + usePolygonFilterPositions, } from '@/features/omrr/omrr-slice' import PolygonIcon from '@/assets/svgs/fa-polygon.svg?react' @@ -17,6 +18,8 @@ interface Props { export function PolygonSearchButton({ isActive }: Readonly) { const dispatch = useDispatch() + const positions = usePolygonFilterPositions() + const isFilterActive = positions.length > 0 const onClick = () => { if (isActive) { @@ -39,10 +42,26 @@ export function PolygonSearchButton({ isActive }: Readonly) { isActive && 'map-button--active', )} startIcon={ - +
+ + {isFilterActive && ( +
+ )} +
} onClick={onClick} >