diff --git a/client/cypress/integration/LegacyTests/mapZoomLatLong.spec.js b/client/cypress/integration/LegacyTests/mapZoomLatLong.spec.js index 4e723d31..46ca7903 100644 --- a/client/cypress/integration/LegacyTests/mapZoomLatLong.spec.js +++ b/client/cypress/integration/LegacyTests/mapZoomLatLong.spec.js @@ -6,7 +6,7 @@ describe('Does the map zoom and adjust to lat/long correctly?', () => { cy.url().should('include', '#3'); }); it('should change to level 4 when you hit the zoom button', () => { - cy.get('.mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-in').click(); + cy.get('.mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-in').click({force: true}); cy.url().should('include', '#4'); }); it('should show the correct lat/lng coordinates in the URL', diff --git a/client/src/components/J40Map.module.scss b/client/src/components/J40Map.module.scss index 5fa1d9fb..a500ef7c 100644 --- a/client/src/components/J40Map.module.scss +++ b/client/src/components/J40Map.module.scss @@ -1,3 +1,4 @@ +@use '../styles/design-system.scss' as *; @import "./utils.scss"; .j40Popup { @@ -5,8 +6,8 @@ } .navigationControl { - left: 1.25em; - top: 2.5em; + left: .75em; + top: units(15); width: 2.5em; } diff --git a/client/src/components/J40Map.tsx b/client/src/components/J40Map.tsx index f77ad518..ef0cb021 100644 --- a/client/src/components/J40Map.tsx +++ b/client/src/components/J40Map.tsx @@ -22,9 +22,10 @@ import {useWindowSize} from 'react-use'; import {useFlags} from '../contexts/FlagContext'; // Components: -import TerritoryFocusControl from './territoryFocusControl'; -import MapInfoPanel from './mapInfoPanel'; import AreaDetail from './AreaDetail'; +import MapInfoPanel from './mapInfoPanel'; +import MapSearch from './MapSearch'; +import TerritoryFocusControl from './territoryFocusControl'; // Styles and constants import {makeMapStyle} from '../data/mapStyle'; @@ -52,6 +53,7 @@ export interface IDetailViewInterface { properties: constants.J40Properties, }; + const J40Map = ({location}: IJ40Interface) => { // Hash portion of URL is of the form #zoom/lat/lng const [zoom, lat, lng] = location.hash.slice(1).split('/'); @@ -176,6 +178,23 @@ const J40Map = ({location}: IJ40Interface) => { return ( <> + + {/* + The MapSearch component is wrapped in a div in order for MapSearch to render correctly in a production build. + + When the MapSearch component is placed behind a feature flag without a div wrapping + MapSearch, the production build will inject CSS due to the null in the false conditional + case. Any changes to this (ie, changes to MapSearch or removing feature flag, etc), should + be tested with a production build via: + + npm run clean && npm run build && npm run serve + + to ensure the production build works and that MapSearch and the map (ReactMapGL) render correctly. + */} +
+ {'sr' in flags ? : null} +
+ { {geolocationInProgress ?
Geolocation in progress...
: ''} {'fs' in flags ? :'' } +
diff --git a/client/src/components/MapSearch/MapSearch.module.scss b/client/src/components/MapSearch/MapSearch.module.scss new file mode 100644 index 00000000..b11b63ec --- /dev/null +++ b/client/src/components/MapSearch/MapSearch.module.scss @@ -0,0 +1,9 @@ +@use '../../styles/design-system.scss' as *; + +.mapSearchContainer { + position: absolute; + top: units(4); + left: units(1.5); + width: 50%; + z-index: 1; +} diff --git a/client/src/components/MapSearch/MapSearch.module.scss.d.ts b/client/src/components/MapSearch/MapSearch.module.scss.d.ts new file mode 100644 index 00000000..3c5d4ae0 --- /dev/null +++ b/client/src/components/MapSearch/MapSearch.module.scss.d.ts @@ -0,0 +1,13 @@ +declare namespace MapSearchModuleScssNamespace { + export interface IMapSearchModuleScss { + mapSearchContainer: string; + } + } + +declare const MapSearchModuleScssModule: MapSearchModuleScssNamespace.IMapSearchModuleScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: MapSearchModuleScssNamespace.IMapSearchModuleScss; + }; + + export = MapSearchModuleScssModule; + diff --git a/client/src/components/MapSearch/MapSearch.test.tsx b/client/src/components/MapSearch/MapSearch.test.tsx new file mode 100644 index 00000000..dc5d29bc --- /dev/null +++ b/client/src/components/MapSearch/MapSearch.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import {render} from '@testing-library/react'; +import {LocalizedComponent} from '../../test/testHelpers'; +import MapSearch from './MapSearch'; + +describe('rendering of the MapSearch', () => { + const mockGoToPlace = jest.fn((x) => x); + + const {asFragment} = render( + + + , + ); + + it('checks if component renders', () => { + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/client/src/components/MapSearch/MapSearch.tsx b/client/src/components/MapSearch/MapSearch.tsx new file mode 100644 index 00000000..851256a5 --- /dev/null +++ b/client/src/components/MapSearch/MapSearch.tsx @@ -0,0 +1,72 @@ +import React, {useState} from 'react'; +import {LngLatBoundsLike} from 'mapbox-gl'; +import {useIntl} from 'gatsby-plugin-intl'; +import {Search} from '@trussworks/react-uswds'; + +import MapSearchMessage from '../MapSearchMessage'; + +import * as styles from './MapSearch.module.scss'; +import * as EXPLORE_COPY from '../../data/copy/explore'; + +interface IMapSearch { + goToPlace(bounds: LngLatBoundsLike):void; +} + +const MapSearch = ({goToPlace}:IMapSearch) => { + // State to hold if the search results are empty or not: + const [isSearchResultsNull, setIsSearchResultsNull] = useState(false); + const intl = useIntl(); + + /* + onSearchHandler will + 1. extract the search term from the input field + 2. fetch data from the API and return the results as JSON and results to US only + 3. if data is valid, destructure the boundingBox values from the search results + 4. pan the map to that location + */ + const onSearchHandler = async (event: React.FormEvent) => { + event.preventDefault(); + + const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value; + + const searchResults = await fetch( + `https://nominatim.openstreetmap.org/search?q=${searchTerm}&format=json&countrycodes=us`, + { + mode: 'cors', + }) + .then((response) => { + if (!response.ok) { + throw new Error('Network response was not OK'); + } + return response.json(); + }) + .catch((error) => { + console.error('There has been a problem with your fetch operation:', error); + }); + + + // If results are valid, set isSearchResultsNull to false and pan map to location: + if (searchResults && searchResults.length > 0) { + setIsSearchResultsNull(false); + console.log('Nominatum search results: ', searchResults); + + const [latMin, latMax, longMin, longMax] = searchResults[0].boundingbox; + goToPlace([[Number(longMin), Number(latMin)], [Number(longMax), Number(latMax)]]); + } else { + setIsSearchResultsNull(true); + } + }; + + return ( +
+ + onSearchHandler(e)} + /> +
+ ); +}; + +export default MapSearch; diff --git a/client/src/components/MapSearch/__snapshots__/MapSearch.test.tsx.snap b/client/src/components/MapSearch/__snapshots__/MapSearch.test.tsx.snap new file mode 100644 index 00000000..8fb67c6f --- /dev/null +++ b/client/src/components/MapSearch/__snapshots__/MapSearch.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rendering of the MapSearch checks if component renders 1`] = ` + +
+
+ No location found. Please try another location. +
+ +
+
+`; diff --git a/client/src/components/MapSearch/index.tsx b/client/src/components/MapSearch/index.tsx new file mode 100644 index 00000000..3d147e7c --- /dev/null +++ b/client/src/components/MapSearch/index.tsx @@ -0,0 +1,3 @@ +import MapSearch from './MapSearch'; + +export default MapSearch; diff --git a/client/src/components/MapSearchMessage/MapSearchMessage.module.scss b/client/src/components/MapSearchMessage/MapSearchMessage.module.scss new file mode 100644 index 00000000..deb31629 --- /dev/null +++ b/client/src/components/MapSearchMessage/MapSearchMessage.module.scss @@ -0,0 +1,18 @@ +@use '../../styles/design-system.scss' as *; + +@mixin searchMessageLayout { + color: red; + background-color: white; + @include u-margin-bottom(.5); + @include u-padding-left(1); +} + +.showMessage { + @include searchMessageLayout; + display: block; +} + +.hideMessage { + @include searchMessageLayout; + visibility: hidden; +} \ No newline at end of file diff --git a/client/src/components/MapSearchMessage/MapSearchMessage.module.scss.d.ts b/client/src/components/MapSearchMessage/MapSearchMessage.module.scss.d.ts new file mode 100644 index 00000000..bf78bae8 --- /dev/null +++ b/client/src/components/MapSearchMessage/MapSearchMessage.module.scss.d.ts @@ -0,0 +1,14 @@ +declare namespace MapSearchMessageModuleScssNamespace { + export interface IMapSearchMessageModuleScss { + showMessage: string; + hideMessage: string; + } + } + +declare const MapSearchMessageModuleScssModule: MapSearchMessageModuleScssNamespace.IMapSearchMessageModuleScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: MapSearchMessageModuleScssNamespace.IMapSearchMessageModuleScss; + }; + + export = MapSearchMessageModuleScssModule; + diff --git a/client/src/components/MapSearchMessage/MapSearchMessage.test.tsx b/client/src/components/MapSearchMessage/MapSearchMessage.test.tsx new file mode 100644 index 00000000..7c28355b --- /dev/null +++ b/client/src/components/MapSearchMessage/MapSearchMessage.test.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import {render} from '@testing-library/react'; +import {LocalizedComponent} from '../../test/testHelpers'; +import MapSearchMessage from './MapSearchMessage'; + +describe('rendering of the MapSearchMessage when search results are empty', () => { + const {asFragment} = render( + + + , + ); + + it('checks if component renders', () => { + expect(asFragment()).toMatchSnapshot(); + }); +}); + +describe('rendering of the MapSearchMessage when search results are not empty', () => { + const {asFragment} = render( + + + , + ); + + it('checks if component renders', () => { + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/client/src/components/MapSearchMessage/MapSearchMessage.tsx b/client/src/components/MapSearchMessage/MapSearchMessage.tsx new file mode 100644 index 00000000..802599db --- /dev/null +++ b/client/src/components/MapSearchMessage/MapSearchMessage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import {useIntl} from 'gatsby-plugin-intl'; + +import * as EXPLORE_COPY from '../../data/copy/explore'; +import * as styles from './MapSearchMessage.module.scss'; + +interface ISearchMessage { + isSearchResultsNull: boolean; +}; + +const MapSearchMessage = ({isSearchResultsNull}:ISearchMessage) => { + const intl = useIntl(); + + return ( +
+ {intl.formatMessage(EXPLORE_COPY.MAP.SEARCH_RESULTS_EMPTY_MESSAGE)} +
+ ); +}; + +export default MapSearchMessage; diff --git a/client/src/components/MapSearchMessage/__snapshots__/MapSearchMessage.test.tsx.snap b/client/src/components/MapSearchMessage/__snapshots__/MapSearchMessage.test.tsx.snap new file mode 100644 index 00000000..a0e92294 --- /dev/null +++ b/client/src/components/MapSearchMessage/__snapshots__/MapSearchMessage.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rendering of the MapSearchMessage when search results are empty checks if component renders 1`] = ` + +
+ No location found. Please try another location. +
+
+`; + +exports[`rendering of the MapSearchMessage when search results are not empty checks if component renders 1`] = ``; diff --git a/client/src/components/MapSearchMessage/index.tsx b/client/src/components/MapSearchMessage/index.tsx new file mode 100644 index 00000000..412f96b3 --- /dev/null +++ b/client/src/components/MapSearchMessage/index.tsx @@ -0,0 +1,3 @@ +import MapSearchMessage from './MapSearchMessage'; + +export default MapSearchMessage; diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index 5fd78d59..7a7baef2 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -5,6 +5,7 @@ import {URLFlagProvider} from '../contexts/FlagContext'; import J40Header from './J40Header'; import J40Footer from './J40Footer'; + interface ILayoutProps { children: ReactNode, location: Location, diff --git a/client/src/components/territoryFocusControl.module.scss b/client/src/components/territoryFocusControl.module.scss index a1932560..31f215da 100644 --- a/client/src/components/territoryFocusControl.module.scss +++ b/client/src/components/territoryFocusControl.module.scss @@ -1,8 +1,10 @@ +@use '../styles/design-system.scss' as *; + .territoryFocusContainer { display: flex; flex-direction: column; position: absolute; - left: 20px; - top: 150px; + left: .75em; + top: units(card-lg); z-index: 10; } diff --git a/client/src/data/copy/explore.tsx b/client/src/data/copy/explore.tsx index 82f2c51e..0e7bd75c 100644 --- a/client/src/data/copy/explore.tsx +++ b/client/src/data/copy/explore.tsx @@ -97,6 +97,16 @@ export const MAP = defineMessages({ defaultMessage: 'Puerto Rico', description: 'The full name indicating the bounds of Puerto Rico', }, + SEARCH_PLACEHOLDER: { + id: 'map.search.placeholder.text', + defaultMessage: 'Enter a city, state or ZIP', + description: 'placeholder text for search', + }, + SEARCH_RESULTS_EMPTY_MESSAGE: { + id: 'map.search.results.empty.text', + defaultMessage: 'No location found. Please try another location.', + description: 'text displaying message for no search results found', + }, }); // Side Panel copy