[Draft] Adds Nominatum search behind a feature flag (#935)

* Add intial search component

* Add nominatum simple

* Connect search field to Nominatum API

- remove react-query
- remove react-query logic from J40Map
- move searchHandler to MapSearch

* Adjust zoom and territory focus

- adjust zoom buttons in CSS to allow for search field

* Place search behind a feature flag

* Add cors to fetch and error handling

- this is to test on OMB machines

* Add error messaging and bound search results to US

- adjust controls to add error message to search
- add MapSearchMessage component for error message
- add unit tests
- add state to track if API results are empty
- add intl on two strings, placeholder and error message

* Remove warpper around MapSearch component

- reorder component import in J40Map
- remove unused CSS in MapSearch.module.scss
- remove and comment on wrapper error on MapSearch
- rename isSearchEmpty to isSearchResultsEmpty
- update snapshot

* Add error message

- if the search query returns null, show an error message
This commit is contained in:
Vim 2021-12-03 10:56:15 -05:00 committed by GitHub
commit 8e31ca032c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 294 additions and 7 deletions

View file

@ -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',

View file

@ -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;
}

View file

@ -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 (
<>
<Grid col={12} desktop={{col: 9}}>
{/*
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.
*/}
<div>
{'sr' in flags ? <MapSearch goToPlace={goToPlace}/> : null}
</div>
<ReactMapGL
{...viewport}
mapStyle={makeMapStyle(flags)}
@ -256,6 +275,7 @@ const J40Map = ({location}: IJ40Interface) => {
{geolocationInProgress ? <div>Geolocation in progress...</div> : ''}
<TerritoryFocusControl onClickTerritoryFocusButton={onClickTerritoryFocusButton}/>
{'fs' in flags ? <FullscreenControl className={styles.fullscreenControl}/> :'' }
</ReactMapGL>
</Grid>

View file

@ -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;
}

View file

@ -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;

View file

@ -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(
<LocalizedComponent>
<MapSearch goToPlace={mockGoToPlace}/>
</LocalizedComponent>,
);
it('checks if component renders', () => {
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -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<HTMLFormElement>) => {
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 (
<div className={styles.mapSearchContainer}>
<MapSearchMessage isSearchResultsNull={isSearchResultsNull} />
<Search
placeholder={intl.formatMessage(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER)}
size="small"
onSubmit={(e) => onSearchHandler(e)}
/>
</div>
);
};
export default MapSearch;

View file

@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rendering of the MapSearch checks if component renders 1`] = `
<DocumentFragment>
<div>
<div>
No location found. Please try another location.
</div>
<form
class="usa-search usa-search--small"
data-testid="form"
role="search"
>
<label
class="usa-sr-only"
data-testid="label"
for="search-field"
>
Search
</label>
<input
class="usa-input"
data-testid="textInput"
id="search-field"
name="search"
placeholder="Enter a city, state or ZIP"
type="search"
/>
<button
class="usa-button"
data-testid="button"
type="submit"
>
<span
class="usa-sr-only"
>
Search
</span>
</button>
</form>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,3 @@
import MapSearch from './MapSearch';
export default MapSearch;

View file

@ -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;
}

View file

@ -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;

View file

@ -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(
<LocalizedComponent>
<MapSearchMessage isSearchEmpty={true}/>
</LocalizedComponent>,
);
it('checks if component renders', () => {
expect(asFragment()).toMatchSnapshot();
});
});
describe('rendering of the MapSearchMessage when search results are not empty', () => {
const {asFragment} = render(
<LocalizedComponent>
<MapSearchMessage isSearchEmpty={false}/>
</LocalizedComponent>,
);
it('checks if component renders', () => {
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -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 (
<div className={isSearchResultsNull ? styles.showMessage : styles.hideMessage}>
{intl.formatMessage(EXPLORE_COPY.MAP.SEARCH_RESULTS_EMPTY_MESSAGE)}
</div>
);
};
export default MapSearchMessage;

View file

@ -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`] = `
<DocumentFragment>
<div>
No location found. Please try another location.
</div>
</DocumentFragment>
`;
exports[`rendering of the MapSearchMessage when search results are not empty checks if component renders 1`] = `<DocumentFragment />`;

View file

@ -0,0 +1,3 @@
import MapSearchMessage from './MapSearchMessage';
export default MapSearchMessage;

View file

@ -5,6 +5,7 @@ import {URLFlagProvider} from '../contexts/FlagContext';
import J40Header from './J40Header';
import J40Footer from './J40Footer';
interface ILayoutProps {
children: ReactNode,
location: Location,

View file

@ -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;
}

View file

@ -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