Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VACMS 20241 - autosuggest/typeahead/combobox address for facility locator #34361

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b3f0314
initial
eselkin Jan 22, 2025
7b516e8
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Jan 22, 2025
6b8db34
working with figma and autosuggest specific options
eselkin Jan 28, 2025
3c853b1
cleanup
eselkin Jan 28, 2025
7ae7972
merge
eselkin Jan 28, 2025
c1b5dab
clean flipper
eselkin Jan 28, 2025
3f4ca99
fix conflict
eselkin Jan 29, 2025
d574ffa
typos
eselkin Jan 29, 2025
ae249d6
put back focus in helper function
eselkin Jan 29, 2025
5945aaf
make AddressTypeahead work with errorMessages for either flipper state
eselkin Jan 29, 2025
05ee225
loading when searching for address
eselkin Jan 29, 2025
dd34b56
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Jan 29, 2025
3af99c3
add newline
eselkin Jan 29, 2025
0d22f8d
add tests
eselkin Jan 30, 2025
756953c
cleanup
eselkin Jan 30, 2025
0a8ea22
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Jan 30, 2025
804d501
fix alignment
eselkin Jan 31, 2025
4380f3c
simplify checks for errors
eselkin Jan 31, 2025
6c42a7d
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Jan 31, 2025
2a41ec9
autosuggest - cannot rename flipper
eselkin Jan 31, 2025
6f54195
Merge branch 'VACMS-20241-autosuggest-valid-address-mapbox' of github…
eselkin Jan 31, 2025
642624d
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Jan 31, 2025
c6bf05e
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Jan 31, 2025
343b85d
accidentally changed flipper name
eselkin Jan 31, 2025
f6f4c6a
prop not props
eselkin Jan 31, 2025
e3bd272
better test coverage
eselkin Feb 1, 2025
6d6c48c
add more testing
eselkin Feb 3, 2025
c38edd2
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Feb 4, 2025
679e3ff
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Feb 4, 2025
3359518
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Feb 4, 2025
877ad6a
Merge branch 'main' into VACMS-20241-autosuggest-valid-address-mapbox
eselkin Feb 4, 2025
ef87190
fix issues on Autosuggest - cities only?
eselkin Feb 5, 2025
c7cce5c
Merge branch 'VACMS-20241-autosuggest-valid-address-mapbox' of github…
eselkin Feb 5, 2025
676679e
change name of value
eselkin Feb 5, 2025
2b53dd8
line breaks
eselkin Feb 5, 2025
93542bc
returned the search to the correct parameters so the thing that is se…
eselkin Feb 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
"dotenv": "^10.0.0",
"downloadjs": "^1.4.7",
"downshift": "^1.22.5",
"downshift-v9": "npm:downshift@^9.0.8",
"express": "^4.17.1",
"fast-levenshtein": "^2.0.6",
"fine-uploader": "^5.16.2",
Expand Down Expand Up @@ -452,4 +453,4 @@
]
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import mbxGeo from '@mapbox/mapbox-sdk/services/geocoding';
import {
isPostcode,
MAPBOX_QUERY_TYPES,
CountriesList,
mapboxClient,
Expand Down Expand Up @@ -38,10 +39,7 @@ export const genBBoxFromAddress = (query, expandedRadius = false) => {

// commas can be stripped from query if Mapbox is returning unexpected results
let types = MAPBOX_QUERY_TYPES;
// check for postcode search
const isPostcode = query.searchString.match(/^\s*\d{5}\s*$/);

if (isPostcode) {
if (isPostcode(query.searchString?.trim() || '')) {
types = ['postcode'];
}
mbxClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { SEARCH_QUERY_UPDATED } from '../actionTypes';
* @param {Object} query The current state of the Search form
*/

export const updateSearchQuery = query => ({
type: SEARCH_QUERY_UPDATED,
payload: { ...query },
});
export const updateSearchQuery = query => {
return {
type: SEARCH_QUERY_UPDATED,
payload: { ...query },
};
};
208 changes: 208 additions & 0 deletions src/applications/facility-locator/components/AddressAutosuggest.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import React, { useCallback, useEffect, useState } from 'react';
import vaDebounce from 'platform/utilities/data/debounce';
import recordEvent from 'platform/monitoring/record-event';
import PropTypes from 'prop-types';
import UseMyLocation from './UseMyLocation';
import AddressInputError from './AddressInputError';
import { searchAddresses } from '../utils/mapHelpers';
import Autosuggest from './Autosuggest';

const MIN_SEARCH_CHARS = 3;
const onlySpaces = str => /^\s+$/.test(str);

function AddressAutosuggest({
currentQuery,
geolocateUser,
inputRef,
onClearClick,
onChange,
}) {
const [inputValue, setInputValue] = useState(null);
const { locationChanged, searchString, geolocationInProgress } = currentQuery;
const [selectedItem, setSelectedItem] = useState(null);
const [options, setOptions] = useState([]);
const [showAddressError, setShowAddressError] = useState(false);
const [isTouched, setIsTouched] = useState(false);
const [isGeocoding, setIsGeocoding] = useState(false);

const inputClearClick = useCallback(
() => {
onClearClick(); // clears searchString in redux
onChange({ searchString: '' });
setInputValue('');
// setting to null causes the the searchString to be used, because of a useEffect below
// so we set it to a non-existent object
setSelectedItem(null);
setOptions([]);
},
[onClearClick, onChange],
);

const handleOnSelect = item => {
// This selects the WHOLE item not just the text to display giving you access to all it's data
if (!item || item.disabled) {
// just do nothing if it's disabled -- could also clear the input or do whatever
return;
}
setSelectedItem(item);
onChange({
searchString: onlySpaces(item.toDisplay)
? item.toDisplay.trim()
: item.toDisplay,
});
};

/**
* updateSearch
* @param {string} term
* @returns {void}
* updateSearch is not called directly but debounced below
*/
const updateSearch = term => {
const trimmedTerm = term?.trim();
if (trimmedTerm === searchString?.trim()) {
return; // already have the values
}
if (trimmedTerm.length >= MIN_SEARCH_CHARS) {
// fetch results and set options
setIsGeocoding(true);
searchAddresses(trimmedTerm)
.then(features => {
if (!features) {
setOptions([]);
} else {
setOptions([
...features.map(feature => ({
...feature,
toDisplay: feature.place_name || trimmedTerm,
})),
]);
}
setIsGeocoding(false);
})
.catch(() => setIsGeocoding(false));
}
};

const handleGeolocationButtonClick = e => {
e.preventDefault();
recordEvent({
event: 'fl-get-geolocation',
});
geolocateUser();
};

const debouncedUpdateSearch = vaDebounce(500, updateSearch);

const onBlur = () => {
const iv = inputValue?.trim();
onChange({ searchString: ' ' });
onChange({ searchString: iv || '' });
// not expected to search when user leaves the field
};

const handleInputChange = e => {
const { inputValue: value } = e;
setInputValue(value);
setIsTouched(true);

if (!value?.trim()) {
onClearClick();
return;
}

debouncedUpdateSearch(value);
};

useEffect(
() => {
// If the location is changed, and there is no value in searchString or inputValue then show the error
setShowAddressError(
locationChanged &&
!geolocationInProgress &&
!searchString?.length &&
!inputValue, // not null but empty string (null on start)
);
},
[
locationChanged,
geolocationInProgress,
searchString,
inputValue,
isTouched,
],
);

useEffect(
() => {
if (searchString && !geolocationInProgress) {
setInputValue(searchString);
}
},
[searchString, geolocationInProgress],
);

return (
<Autosuggest
inputValue={inputValue || ''}
onInputValueChange={handleInputChange}
selectedItem={selectedItem || null}
handleOnSelect={handleOnSelect}
/* eslint-disable prettier/prettier */

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ESLint disabled here

label={(
<>
<span id="city-state-zip-text">City, state or postal code</span>{' '}
<span className="form-required-span">(*Required)</span>
</>
)}
/* eslint-enable prettier/prettier */
options={options}
downshiftInputProps={{
// none are required
id: 'street-city-state-zip', // not required to provide an id
onFocus: () => setIsTouched(true), // not required
onBlur, // override the onBlur to handle that we want to keep the data and update the search in redux
disabled: false,
autoCorrect: 'off',
spellCheck: 'false',
onChange: e => {
// possibly necessary if you see input jumping around
handleInputChange({ inputValue: e.target.value });
},
}}
onClearClick={inputClearClick}
inputError={<AddressInputError showError={showAddressError || false} />}
showError={showAddressError}
inputId="street-city-state-zip"
inputRef={inputRef}
/* eslint-disable prettier/prettier */

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ESLint disabled here

labelSibling={(
<UseMyLocation
onClick={handleGeolocationButtonClick}
geolocationInProgress={currentQuery.geolocationInProgress}
/>
)}
/* eslint-enable prettier/prettier */
minCharacters={MIN_SEARCH_CHARS}
keepDataOnBlur
showDownCaret={false}
shouldShowNoResults={false}
isLoading={isGeocoding}
loadingMessage="Searching..."
/>
);
}

AddressAutosuggest.propTypes = {
onChange: PropTypes.func.isRequired,
currentQuery: PropTypes.shape({
geolocationInProgress: PropTypes.bool,
locationChanged: PropTypes.bool,
searchString: PropTypes.string,
}),
geolocateUser: PropTypes.func,
inputRef: PropTypes.object,
onClearClick: PropTypes.func,
};

export default AddressAutosuggest;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';

function AddressInputError({ showError }) {
if (!showError) {
return null;
}
return (
<span className="usa-input-error-message" role="alert">
<span className="sr-only">Error</span>
Please fill in a city, state, or postal code.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We stopped using Please in other apps per DS recommendations; should we be using it here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's being updated in the progressive disclosure ticket. We're also changing the string associated with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can update here to be in line with that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to update it here to be in line with DS standards (if we know what it should be).

</span>
);
}

AddressInputError.propTypes = {
showError: PropTypes.bool,
};

export default AddressInputError;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';

export const optionClasses = selected =>
classNames('dropdown-option', { selected });

function AutosuggestOption({
highlightedIndex,
index,
item,
getItemProps,
itemToString,
}) {
return (
<p
{...getItemProps({
item,
className: optionClasses(index === highlightedIndex),
role: 'option',
'aria-selected': index === highlightedIndex,
})}
data-testid={`autosuggest-option-${item.id || `${item}-${index}`}`}
>
{itemToString(item)}
</p>
);
}

AutosuggestOption.propTypes = {
getItemProps: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
item: PropTypes.object.isRequired,
itemToString: PropTypes.func.isRequired,
highlightedIndex: PropTypes.number, // something may not be higlighted - optional from Downshift
};

export default AutosuggestOption;
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';

function AutosuggestOptions({
getItemProps,
highlightedIndex,
options,
isShown,
isLoading,
loadingMessage,
itemToString,
getMenuProps,
noItemsMessage,
shouldShowNoResults,
AutosuggestOptionComponent,
}) {
// All an option is required to have is an id, toDisplay, and optionally disabled
// Anything else is up to the user. The `id` is used for the key and that's why it is required
const [optionsToShow, setOptionsToShow] = useState([]);
useEffect(
() => {
if (isLoading) {
setOptionsToShow([
{ id: 'loading', disabled: true, toDisplay: loadingMessage },
]);
} else if (!options?.length && shouldShowNoResults) {
setOptionsToShow([
{ id: 'no-items', disabled: true, toDisplay: noItemsMessage },
]);
} else {
setOptionsToShow(options);
}
},
[options, noItemsMessage, shouldShowNoResults, isLoading, loadingMessage],
);

return (
<div
className="dropdown"
{...getMenuProps()}
style={{ display: !isShown || !optionsToShow?.length ? 'none' : 'block' }}
data-testid="autosuggest-options"
>
{optionsToShow.map((item, index) => (
<AutosuggestOptionComponent
key={item.id || `${item}-${index}`}
item={item}
index={index}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
itemToString={itemToString}
/>
))}
</div>
);
}

AutosuggestOptions.propTypes = {
getItemProps: PropTypes.func.isRequired,
getMenuProps: PropTypes.func.isRequired,
isShown: PropTypes.bool.isRequired,
itemToString: PropTypes.func.isRequired,
noItemsMessage: PropTypes.string.isRequired,
options: PropTypes.array.isRequired,
shouldShowNoResults: PropTypes.bool.isRequired,
AutosuggestOptionComponent: PropTypes.elementType,
highlightedIndex: PropTypes.number, // something may not be higlighted - optional from Downshift
isLoading: PropTypes.bool,
loadingMessage: PropTypes.string,
};

export default AutosuggestOptions;
Loading
Loading