From 8c741510660af3f55583206b7c8ffff95c383ea2 Mon Sep 17 00:00:00 2001 From: Gabriel Mechali Date: Mon, 7 Oct 2024 13:06:57 -0400 Subject: [PATCH] FE changes to support location autocomplete within the NL Search bar (#4649) All FE & BE changes to add a dropdown on the NL search bar for autocompleting location search. See screencast: https://screencast.googleplex.com/cast/NDkxMjUyMjc3MDUxMzkyMHxkZjg3ZDUxMC05MA Also adds a webdriver test to verify the presence of the suggestion results. --- import | 2 +- mixer | 2 +- server/__init__.py | 4 + .../shared_api/autocomplete/autocomplete.py | 63 +++++ .../routes/shared_api/autocomplete/helpers.py | 167 +++++++++++ server/routes/shared_api/place.py | 19 +- server/tests/routes/api/autocomplete_test.py | 67 +++++ server/tests/routes/api/mock_data.py | 40 +++ server/webdriver/tests/homepage_test.py | 25 ++ static/css/core.scss | 72 ++++- static/js/components/nl_search_bar.tsx | 8 +- .../nl_search_bar/auto_complete_input.tsx | 265 ++++++++++++++++++ .../auto_complete_suggestions.tsx | 86 ++++++ .../nl_search_bar_header_inline.tsx | 40 ++- .../nl_search_bar/nl_search_bar_standard.tsx | 40 ++- static/js/shared/util.ts | 15 + static/js/utils/click_alerter.ts | 69 +++++ static/package-lock.json | 4 +- 18 files changed, 917 insertions(+), 71 deletions(-) create mode 100644 server/routes/shared_api/autocomplete/autocomplete.py create mode 100644 server/routes/shared_api/autocomplete/helpers.py create mode 100644 server/tests/routes/api/autocomplete_test.py create mode 100644 static/js/components/nl_search_bar/auto_complete_input.tsx create mode 100644 static/js/components/nl_search_bar/auto_complete_suggestions.tsx create mode 100644 static/js/utils/click_alerter.ts diff --git a/import b/import index fab89e365f..5cbee1470b 160000 --- a/import +++ b/import @@ -1 +1 @@ -Subproject commit fab89e365f0e38cbc87c528f2c00746c04be2d6c +Subproject commit 5cbee1470bc5288d53f49f429105e3e508ffb774 diff --git a/mixer b/mixer index 6b01171cbf..8a5d4ee1a4 160000 --- a/mixer +++ b/mixer @@ -1 +1 @@ -Subproject commit 6b01171cbf081336bbf98185604a43d5113af2ef +Subproject commit 8a5d4ee1a4cbe4b3a757aaf37040082f31a4e1e7 diff --git a/server/__init__.py b/server/__init__.py index b17e384959..97e14af3f9 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -209,6 +209,10 @@ def register_routes_common(app): from server.routes.shared_api import stats as shared_stats app.register_blueprint(shared_stats.bp) + from server.routes.shared_api.autocomplete import \ + autocomplete as shared_autocomplete + app.register_blueprint(shared_autocomplete.bp) + from server.routes.shared_api import variable as shared_variable app.register_blueprint(shared_variable.bp) diff --git a/server/routes/shared_api/autocomplete/autocomplete.py b/server/routes/shared_api/autocomplete/autocomplete.py new file mode 100644 index 0000000000..2dfde5a2b0 --- /dev/null +++ b/server/routes/shared_api/autocomplete/autocomplete.py @@ -0,0 +1,63 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from flask import Blueprint +from flask import request + +from server.routes.shared_api.autocomplete import helpers +from server.routes.shared_api.place import findplacedcid + +# TODO(gmechali): Add Stat Var search. + +# Define blueprint +bp = Blueprint("autocomplete", __name__, url_prefix='/api') + + +@bp.route('/autocomplete') +def autocomplete(): + """Predicts the user query for location only, using the Google Maps prediction API. + Returns: + Json object represnting 5 location predictions for the query. + """ + lang = request.args.get('hl') + query = request.args.get('query') + + # Extract subqueries from the user input. + queries = helpers.find_queries(query) + + # Send requests to the Google Maps Predictions API. + prediction_responses = helpers.predict(queries, lang) + + place_ids = [] + for prediction in prediction_responses: + place_ids.append(prediction["place_id"]) + + place_id_to_dcid = [] + if place_ids: + place_id_to_dcid = json.loads(findplacedcid(place_ids).data) + + final_predictions = [] + # TODO(gmechali): See if we can use typed dataclasses here. + for prediction in prediction_responses: + current_prediction = {} + current_prediction['name'] = prediction['description'] + current_prediction['match_type'] = 'location_search' + current_prediction['matched_query'] = prediction['matched_query'] + if prediction['place_id'] in place_id_to_dcid: + current_prediction['dcid'] = place_id_to_dcid[prediction['place_id']] + final_predictions.append(current_prediction) + + return {'predictions': final_predictions} \ No newline at end of file diff --git a/server/routes/shared_api/autocomplete/helpers.py b/server/routes/shared_api/autocomplete/helpers.py new file mode 100644 index 0000000000..f4bb0af03f --- /dev/null +++ b/server/routes/shared_api/autocomplete/helpers.py @@ -0,0 +1,167 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import re +from typing import Dict, List +from urllib.parse import urlencode + +from flask import current_app +import requests + +MAPS_API_URL = "https://maps.googleapis.com/maps/api/place/autocomplete/json?" +MIN_CHARACTERS_PER_QUERY = 3 +MAX_NUM_OF_QUERIES = 4 +RESPONSE_COUNT_LIMIT = 10 +DISPLAYED_RESPONSE_COUNT_LIMIT = 5 + + +def find_queries(user_query: str) -> List[str]: + """Extracts subqueries to send to the Google Maps Predictions API from the entire user input. + Returns: + List[str]: containing all subqueries to execute. + """ + rgx = re.compile(r'\s+') + words_in_query = re.split(rgx, user_query) + queries = [] + cumulative = "" + for word in reversed(words_in_query): + # Extract at most 3 subqueries. + if len(queries) >= MAX_NUM_OF_QUERIES: + break + + # Prepend the current word for the next subquery. + if len(cumulative) > 0: + cumulative = word + " " + cumulative + else: + cumulative = word + + # Only send queries 3 characters or longer. + if (len(cumulative) >= MIN_CHARACTERS_PER_QUERY): + queries.append(cumulative) + + # Start by running the longer queries. + queries.reverse() + return queries + + +def execute_maps_request(query: str, language: str) -> Dict: + """Execute a request to the Google Maps Prediction API for a given query. + Returns: + Json object containing the google maps prediction response. + """ + request_obj = { + 'types': "(regions)", + 'key': current_app.config['MAPS_API_KEY'], + 'input': query, + 'language': language + } + response = requests.post(MAPS_API_URL + urlencode(request_obj), json={}) + return json.loads(response.text) + + +def get_match_score(name: str, match_string: str) -> float: + """Computes a 'score' based on the matching words in two strings. + Returns: + Float score.""" + rgx = re.compile(r'\s+') + words_in_name = re.split(rgx, name) + words_in_str1 = re.split(rgx, match_string) + + score = 0 + for str1_word in words_in_str1: + str1_word = str1_word.lower() + for name_word in words_in_name: + name_word = name_word.lower() + if str1_word == name_word: + score += 1 + break + elif str1_word in name_word: + score += 0.5 + break + else: + score -= 1 + + return score + + +def find_best_match(name: str, string1: str, string2: str) -> str: + """Finds the best match between string1 and string2 for name. We use a very + simple algorithm based on approximate accuracy. + Returns: + String that is the better match. + """ + + # Note that this function is implemented to find the best "matched_query", when the same response + # is found multiple times. + # For example: + # name: "California, USA" + # string1: "Of Calif" + # string2: "Calif" + # should return "Calif" as a better match. + score1 = get_match_score(name, string1) + score2 = get_match_score(name, string2) + + if score2 > score1: + return string2 + + return string1 + + +def predict(queries: List[str], lang: str) -> List[Dict]: + """Trigger maps prediction api requests and parse the output. Remove duplication responses and limit the number of results. + Returns: + List of json objects containing predictions from all queries issued after deduping. + """ + responses = [] + place_ids = set() + duplicates = {} + + for query in queries: + predictions_for_query = execute_maps_request(query, lang)['predictions'] + + for pred in predictions_for_query: + pred['matched_query'] = query + if pred['place_id'] not in place_ids: + place_ids.add(pred['place_id']) + responses.append(pred) + else: + if pred['place_id'] in duplicates: + # find best match + # print("Second dupe.") + bm = find_best_match(pred['description'], + duplicates[pred['place_id']], query) + # print("BM won: ") + # print(bm) + duplicates[pred['place_id']] = bm + else: + # print("We're just getting our first dupe.") + duplicates[pred['place_id']] = query + + if len(responses) >= RESPONSE_COUNT_LIMIT: + # prevent new loop to iterate through next answer. + break + + if len(responses) >= RESPONSE_COUNT_LIMIT: + # prevent new loop that will make new request to maps api. + break + + responses = responses[:DISPLAYED_RESPONSE_COUNT_LIMIT] + for resp in responses: + if resp['place_id'] in duplicates: + best_match = find_best_match(resp['description'], resp['matched_query'], + duplicates[resp['place_id']]) + resp["matched_query"] = best_match + + return responses diff --git a/server/routes/shared_api/place.py b/server/routes/shared_api/place.py index b6993198a6..1f5219aa2b 100644 --- a/server/routes/shared_api/place.py +++ b/server/routes/shared_api/place.py @@ -676,14 +676,7 @@ def descendent_names(): return Response(json.dumps(result), 200, mimetype='application/json') -@bp.route('/placeid2dcid') -def placeid2dcid(): - """API endpoint to get dcid based on place id. - - This is to use together with the Google Maps Autocomplete API: - https://developers.google.com/places/web-service/autocomplete. - """ - place_ids = request.args.getlist("placeIds") +def findplacedcid(place_ids): if not place_ids: return 'error: must provide `placeIds` field', 400 resp = fetch.resolve_id(place_ids, "placeId", "dcid") @@ -697,6 +690,16 @@ def placeid2dcid(): return Response(json.dumps(result), 200, mimetype='application/json') +@bp.route('/placeid2dcid') +def placeid2dcid(): + """API endpoint to get dcid based on place id. + This is to use together with the Google Maps Autocomplete API: + https://developers.google.com/places/web-service/autocomplete. + """ + place_ids = request.args.getlist("placeIds") + return findplacedcid(place_ids) + + @bp.route('/coords2places') def coords2places(): """API endpoint to get place name and dcid based on latitude/longitude diff --git a/server/tests/routes/api/autocomplete_test.py b/server/tests/routes/api/autocomplete_test.py new file mode 100644 index 0000000000..72c9c48a31 --- /dev/null +++ b/server/tests/routes/api/autocomplete_test.py @@ -0,0 +1,67 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +import unittest +from unittest.mock import patch + +import server.tests.routes.api.mock_data as mock_data +from web_app import app + + +class TestAutocomplete(unittest.TestCase): + + def run_autocomplete_query(self, query: str, lang: str): + return app.test_client().get( + "/api/autocomplete?query=`${query}`&hl=${lang}", json={}) + + lang = 'en' + + @patch('server.routes.shared_api.autocomplete.helpers.predict') + @patch('server.routes.shared_api.place.fetch.resolve_id') + def test_empty_query(self, mock_resolve_ids, mock_predict): + + def resolve_ids_side_effect(nodes, in_prop, out_prop): + return [] + + def mock_predict_effect(query, lang): + return [] + + mock_resolve_ids.side_effect = resolve_ids_side_effect + mock_predict.side_effect = mock_predict_effect + + response = self.run_autocomplete_query('', 'en') + self.assertEqual(response.status_code, 200) + + response_dict = json.loads(response.data.decode("utf-8")) + self.assertEqual(len(response_dict["predictions"]), 0) + + @patch('server.routes.shared_api.autocomplete.helpers.predict') + @patch('server.routes.shared_api.place.fetch.resolve_id') + def test_single_word_query(self, mock_resolve_ids, mock_predict): + + def resolve_ids_side_effect(nodes, in_prop, out_prop): + return mock_data.RESOLVE_IDS_VALUES + + def mock_predict_effect(query, lang): + return mock_data.MAPS_PREDICTIONS_VALUES + + mock_resolve_ids.side_effect = resolve_ids_side_effect + mock_predict.side_effect = mock_predict_effect + + response = self.run_autocomplete_query('Calif', 'en') + + self.assertEqual(response.status_code, 200) + + response_dict = json.loads(response.data.decode("utf-8")) + self.assertEqual(len(response_dict["predictions"]), 5) \ No newline at end of file diff --git a/server/tests/routes/api/mock_data.py b/server/tests/routes/api/mock_data.py index 7af627e004..6c819b7182 100644 --- a/server/tests/routes/api/mock_data.py +++ b/server/tests/routes/api/mock_data.py @@ -415,3 +415,43 @@ } } } + +RESOLVE_IDS_VALUES = { + 'ChIJPV4oX_65j4ARVW8IJ6IJUYs': [{ + 'dcid': 'geoId/4210768' + }], + 'ChIJPV4oX_65j4ARVW8IJ6IJUYs1': [{ + 'dcid': 'geoId/4210769' + }], + 'ChIJPV4oX_65j4ARVW8IJ6IJUYs2': [{ + 'dcid': 'geoId/4210770' + }], + 'ChIJPV4oX_65j4ARVW8IJ6IJUYs3': [{ + 'dcid': 'geoId/4210771' + }], + 'ChIJPV4oX_65j4ARVW8IJ6IJUYs4': [{ + 'dcid': 'geoId/4210772' + }] +} + +MAPS_PREDICTIONS_VALUES = [{ + 'description': 'California, USA', + 'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs', + 'matched_query': 'calif' +}, { + 'description': 'Califon, NJ, USA', + 'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs1', + 'matched_query': 'calif' +}, { + 'description': 'California, MD, USA', + 'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs2', + 'matched_query': 'calif' +}, { + 'description': 'California City, CA, USA', + 'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs3', + 'matched_query': 'calif' +}, { + 'description': 'California, PA, USA', + 'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs4', + 'matched_query': 'calif' +}] diff --git a/server/webdriver/tests/homepage_test.py b/server/webdriver/tests/homepage_test.py index 9d65ecd0bd..32274a948a 100644 --- a/server/webdriver/tests/homepage_test.py +++ b/server/webdriver/tests/homepage_test.py @@ -90,3 +90,28 @@ def test_homepage_it(self): # hero_msg = self.driver.find_elements(By.CLASS_NAME, 'lead')[0] # self.assertTrue( # hero_msg.text.startswith('Data Commons – это открытая база данных')) + + +# Tests for NL Search Bar AutoComplete feature. + + def test_homepage_autocomplete(self): + """Test homepage autocomplete.""" + + self.driver.get(self.url_ + '/?ac_on=true') + + title_present = EC.text_to_be_present_in_element( + (By.CSS_SELECTOR, '#main-nav .navbar-brand'), 'Data Commons') + WebDriverWait(self.driver, self.TIMEOUT_SEC).until(title_present) + + search_box_input = self.driver.find_element(By.ID, 'query-search-input') + + # Type california into the search box. + search_box_input.send_keys("California") + + suggestions_present = EC.presence_of_element_located( + (By.CLASS_NAME, 'search-input-result-section')) + WebDriverWait(self.driver, 300).until(suggestions_present) + + autocomplete_results = self.driver.find_elements( + By.CLASS_NAME, 'search-input-result-section') + self.assertTrue(len(autocomplete_results) == 5) diff --git a/static/css/core.scss b/static/css/core.scss index 8ae903ceb3..933e004f11 100644 --- a/static/css/core.scss +++ b/static/css/core.scss @@ -580,15 +580,10 @@ section { padding-right: 16px; } - .search-box-section { - width: 100%; - } - .search-bar { align-items: center; background: var(--gm-3-ref-primary-primary-100); - border-radius: 28px; - border: var(--border-primary); + border: none; border-color: var(--gm-3-ref-neutral-neutral-40); .search-bar-content { @@ -637,13 +632,72 @@ section { } } - .search-bar:hover { + .search-box-section { + width: 100%; + background-color: var.$color-blue-bckg; + padding: 2px; + border-radius: 28px; + border: var(--border-primary); + border-color: var(--gm-3-ref-neutral-neutral-40); + + .unradiused { + border-top-left-radius: 28px; + border-top-right-radius: 28px; + } + + .radiused { + border-radius: 28px; + } + + .search-input-results-list { + padding-bottom: 24px; + border-bottom-left-radius: 28px; + border-bottom-right-radius: 28px; + + .search-input-result-section { + display: flex; + flex-direction: column; + padding: 10px 0px; + + .search-input-result { + display: flex; + flex-direction: row; + align-items: center; + padding-left: 24px; + + .query-result { + padding-left: 4px; + + .query-suggestion { + font-weight: 500; + } + } + } + } + + .search-input-result-section-highlighted, + .search-input-result-section:hover { + background-color: var(--gm-3-sys-light-surface-container-high); + } + } + + .result-divider { + margin: 0px; + } + } + + .search-box-section:hover { border-color: var(--gm-3-ref-neutral-neutral-20); } + + .search-box-section-active, + .search-box-section-active:hover { + border-color: var(--gm-3-sys-light-primary); + } + .search-bar:active, .search-bar:focus-within { - border-color: var(--gm-3-sys-light-primary); background: var(--gm-3-ref-primary-primary-99); } @@ -696,4 +750,4 @@ section { } } } -} +} \ No newline at end of file diff --git a/static/js/components/nl_search_bar.tsx b/static/js/components/nl_search_bar.tsx index 08e5460b04..ac94cb15e8 100644 --- a/static/js/components/nl_search_bar.tsx +++ b/static/js/components/nl_search_bar.tsx @@ -21,7 +21,7 @@ import React, { ReactElement, useEffect, useState } from "react"; import NlSearchBarHeaderInline from "./nl_search_bar/nl_search_bar_header_inline"; -import NlSearchBarStandard from "./nl_search_bar/nl_search_bar_standard"; +import { NlSearchBarStandard } from "./nl_search_bar/nl_search_bar_standard"; interface NlSearchBarPropType { variant?: "standard" | "header-inline"; @@ -45,7 +45,7 @@ export interface NlSearchBarImplementationProps { //the id of the input inputId: string; //the change event (used to trigger appropriate state changes in this parent) - onChange: (e: React.ChangeEvent) => void; + onChange: (newValue: string) => void; //a function to be called once a search is run onSearch: () => void; //the autofocus attribute of the input will be set to shouldAutoFocus @@ -76,8 +76,8 @@ export function NlSearchBar(props: NlSearchBarPropType): ReactElement { invalid, placeholder: props.placeholder, inputId: props.inputId, - onChange: (e: React.ChangeEvent) => { - setValue(e.target.value); + onChange: (newValue: string) => { + setValue(newValue); setInvalid(false); }, onSearch: handleSearch, diff --git a/static/js/components/nl_search_bar/auto_complete_input.tsx b/static/js/components/nl_search_bar/auto_complete_input.tsx new file mode 100644 index 0000000000..419a69813a --- /dev/null +++ b/static/js/components/nl_search_bar/auto_complete_input.tsx @@ -0,0 +1,265 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Standard version of the auto-complete capable NL Search bar. + */ + +import axios from "axios"; +import _ from "lodash"; +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Input, InputGroup } from "reactstrap"; + +import { stripPatternFromQuery } from "../../shared/util"; +import { + useInsideClickAlerter, + useOutsideClickAlerter, +} from "../../utils/click_alerter"; +import { AutoCompleteSuggestions } from "./auto_complete_suggestions"; + +const DEBOUNCE_INTERVAL_MS = 100; +const PLACE_EXPLORER_PREFIX = "/place/"; +const LOCATION_SEARCH = "location_search"; + +export interface AutoCompleteResult { + dcid: string; + match_type: string; + matched_query: string; + name: string; +} + +interface AutoCompleteInputPropType { + enableAutoComplete?: boolean; + value: string; + invalid: boolean; + placeholder: string; + inputId: string; + onChange: (query: string) => void; + onSearch: () => void; + feedbackLink: string; + shouldAutoFocus: boolean; + barType: string; +} + +export function AutoCompleteInput( + props: AutoCompleteInputPropType +): ReactElement { + const wrapperRef = useRef(null); + const [baseInput, setBaseInput] = useState(""); + const [inputText, setInputText] = useState(""); + // TODO(gmechali): Implement stat var search. + const [results, setResults] = useState({ placeResults: [], svResults: [] }); + const [hoveredIdx, setHoveredIdx] = useState(-1); + const [triggerSearch, setTriggerSearch] = useState(""); + const [inputActive, setInputActive] = useState(false); + + const isHeaderBar = props.barType == "header"; + let lang = ""; + + useEffect(() => { + // One time initialization of event listener to clear suggested results on scroll. + // It allows the user to navigate through the page without being annoyed by the results. + window.addEventListener("scroll", () => { + if (results.placeResults) { + setResults({ placeResults: [], svResults: [] }); + } + }); + + const urlParams = new URLSearchParams(window.location.search); + lang = urlParams.has("hl") ? urlParams.get("hl") : "en"; + }, []); + + // Clear suggested results when click registered outside of component. + useOutsideClickAlerter(wrapperRef, () => { + setResults({ placeResults: [], svResults: [] }); + setInputActive(false); + }); + + useInsideClickAlerter(wrapperRef, () => { + setInputActive(true); + }); + + useEffect(() => { + // TriggerSearch state used to ensure onSearch only called after text updated. + props.onSearch(); + }, [triggerSearch, setTriggerSearch]); + + function onInputChange(e: React.ChangeEvent): void { + const currentText = e.target.value; + changeText(currentText); + setBaseInput(currentText); + + if (!props.enableAutoComplete) return; + + const selectionApplied = + hoveredIdx >= 0 && + results.placeResults.length >= hoveredIdx && + currentText.trim().endsWith(results.placeResults[hoveredIdx].name); + setHoveredIdx(-1); + + if (_.isEmpty(currentText) || selectionApplied) { + // Reset all suggestion results. + setResults({ placeResults: [], svResults: [] }); + return; + } + + sendDebouncedAutoCompleteRequest(currentText); + } + + const triggerAutoCompleteRequest = useCallback(async (query: string) => { + await axios + .get(`/api/autocomplete?query=${query}&hl=${lang}`, {}) + .then((response) => { + setResults({ + placeResults: response["data"]["predictions"], + svResults: [], + }); + }) + .catch((err) => { + console.log("Error fetching autocomplete suggestions: " + err); + }); + }, []); + + // memoize the debounce call with useMemo + const sendDebouncedAutoCompleteRequest = useMemo(() => { + return _.debounce(triggerAutoCompleteRequest, DEBOUNCE_INTERVAL_MS); + }, []); + + function changeText(text: string): void { + // Update text in Input without triggering search. + setInputText(text); + props.onChange(text); + } + + function handleKeydownEvent( + event: React.KeyboardEvent + ): void { + // Navigate through the suggested results. + switch (event.key) { + case "Enter": + event.preventDefault(); + if (hoveredIdx >= 0) { + selectResult(results.placeResults[hoveredIdx]); + } else { + props.onSearch(); + } + break; + case "ArrowUp": + event.preventDefault(); + processArrowKey(Math.max(hoveredIdx - 1, -1)); + break; + case "ArrowDown": + event.preventDefault(); + processArrowKey( + Math.min(hoveredIdx + 1, results.placeResults.length - 1) + ); + break; + } + } + + function replaceQueryWithSelection( + query: string, + result: AutoCompleteResult + ): string { + return stripPatternFromQuery(query, result.matched_query) + result.name; + } + + function processArrowKey(selectedIndex: number): void { + setHoveredIdx(selectedIndex); + const textDisplayed = + selectedIndex >= 0 + ? replaceQueryWithSelection( + baseInput, + results.placeResults[selectedIndex] + ) + : baseInput; + changeText(textDisplayed); + } + + function selectResult(result: AutoCompleteResult): void { + if ( + result["match_type"] == LOCATION_SEARCH && + stripPatternFromQuery(baseInput, result.matched_query).trim() === "" + ) { + // If this is a location result, and the matched_query matches the base input + // then that means there are no other parts of the query, so it's a place only + // redirection. + if (result.dcid) { + const url = PLACE_EXPLORER_PREFIX + `${result.dcid}`; + window.open(url, "_self"); + return; + } + } + + const newString = replaceQueryWithSelection(baseInput, result); + changeText(newString); + setTriggerSearch(newString); + } + + return ( + <> +
+
+ + {isHeaderBar && ( + search + )} + handleKeydownEvent(event)} + className="pac-target-input search-input-text" + autoComplete="one-time-code" + autoFocus={props.shouldAutoFocus} + > +
+ {isHeaderBar && ( + arrow_forward + )} +
+
+
+ {props.enableAutoComplete && !_.isEmpty(results.placeResults) && ( + + )} +
+ + ); +} diff --git a/static/js/components/nl_search_bar/auto_complete_suggestions.tsx b/static/js/components/nl_search_bar/auto_complete_suggestions.tsx new file mode 100644 index 0000000000..e8ceb8b1da --- /dev/null +++ b/static/js/components/nl_search_bar/auto_complete_suggestions.tsx @@ -0,0 +1,86 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Standard version of the suggested results for the auto-complete capable NL Search bar. + */ + +import React, { ReactElement } from "react"; + +import { stripPatternFromQuery } from "../../shared/util"; +import { AutoCompleteResult } from "./auto_complete_input"; + +interface AutoCompleteSuggestionsPropType { + allResults: AutoCompleteResult[]; + baseInput: string; + onClick: (result: AutoCompleteResult) => void; + hoveredIdx: number; +} + +export function AutoCompleteSuggestions( + props: AutoCompleteSuggestionsPropType +): ReactElement { + function getIcon(query: string, matched_query: string): string { + if (query == matched_query) { + return "location_on"; + } + return "search"; + } + + return ( +
+
+ {props.allResults.map((result: AutoCompleteResult, idx: number) => { + return ( +
+
+
props.onClick(result)} + > + + {getIcon(props.baseInput, result.matched_query)} + +
+ + {stripPatternFromQuery( + props.baseInput, + result.matched_query + )} + {result.name} + +
+
+
+ {idx !== props.allResults.length - 1 ? ( +
+ ) : ( + <> + )} +
+ ); + })} +
+
+ ); +} diff --git a/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx b/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx index ff6285f202..3c302ab6bc 100644 --- a/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx +++ b/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx @@ -19,9 +19,9 @@ */ import React, { ReactElement } from "react"; -import { Input, InputGroup } from "reactstrap"; import { NlSearchBarImplementationProps } from "../nl_search_bar"; +import { AutoCompleteInput } from "./auto_complete_input"; const NlSearchBarHeaderInline = ({ value, @@ -32,29 +32,25 @@ const NlSearchBarHeaderInline = ({ onSearch, shouldAutoFocus, }: NlSearchBarImplementationProps): ReactElement => { + const urlParams = new URLSearchParams(window.location.search); + const enableAutoComplete = urlParams.has("ac_on") + ? urlParams.get("ac_on") == "true" + : false; + return (
-
-
- - search - e.key === "Enter" && onSearch()} - className="pac-target-input search-input-text" - autoFocus={shouldAutoFocus} - autoComplete="off" - > -
- arrow_forward -
-
-
-
+
); }; diff --git a/static/js/components/nl_search_bar/nl_search_bar_standard.tsx b/static/js/components/nl_search_bar/nl_search_bar_standard.tsx index e2fc603cd6..8bf3dbe6f4 100644 --- a/static/js/components/nl_search_bar/nl_search_bar_standard.tsx +++ b/static/js/components/nl_search_bar/nl_search_bar_standard.tsx @@ -19,11 +19,11 @@ */ import React, { ReactElement } from "react"; -import { Input, InputGroup } from "reactstrap"; import { NlSearchBarImplementationProps } from "../nl_search_bar"; +import { AutoCompleteInput } from "./auto_complete_input"; -const NlSearchBarStandard = ({ +export function NlSearchBarStandard({ value, invalid, placeholder, @@ -32,30 +32,22 @@ const NlSearchBarStandard = ({ onSearch, feedbackLink, shouldAutoFocus, -}: NlSearchBarImplementationProps): ReactElement => { +}: NlSearchBarImplementationProps): ReactElement { return (
-
-
- - e.key === "Enter" && onSearch()} - className="pac-target-input search-input-text" - autoFocus={shouldAutoFocus} - autoComplete="off" - > -
-
-
-
+
); -}; - -export default NlSearchBarStandard; +} diff --git a/static/js/shared/util.ts b/static/js/shared/util.ts index 1e81cd853d..81d4eda272 100644 --- a/static/js/shared/util.ts +++ b/static/js/shared/util.ts @@ -178,3 +178,18 @@ export function removeSpinner(containerId: string): void { } } } + +/** + * Removes the pattern parameter from the query if that substring is present at the end. + * @param query the string from which to remove the pattern + * @param pattern a string which we want to find and remove from the query. + * @returns the query with the pattern removed if it was found. + */ +export function stripPatternFromQuery(query: string, pattern: string): string { + const regex = new RegExp("(?:.(?!" + pattern + "))+([,;\\s])?$", "i"); + + // Returns the query without the pattern parameter. + // E.g.: query: "population of Calif", pattern: "Calif", + // returns "population of " + return query.replace(regex, ""); +} diff --git a/static/js/utils/click_alerter.ts b/static/js/utils/click_alerter.ts new file mode 100644 index 0000000000..ed167b79b5 --- /dev/null +++ b/static/js/utils/click_alerter.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MutableRefObject, useEffect } from "react"; + +export function useOutsideClickAlerter( + ref: MutableRefObject, + callbackFunction: () => void +): void { + /** + * Initiates component that calls the callback function when a click is + * registered outside of the referenced component. + */ + useEffect(() => { + /** + * Alert if clicked on outside of element + */ + function handleClickOutside(event: MouseEvent): void { + if (ref.current && !ref.current.contains(event.target as Node)) { + callbackFunction(); + } + } + // Bind the event listener + document.addEventListener("mousedown", handleClickOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [ref]); +} + +export function useInsideClickAlerter( + ref: MutableRefObject, + callbackFunction: () => void +): void { + /** + * Initiates component that calls the callback function when a click is + * registered inside of the referenced component. + */ + useEffect(() => { + /** + * Alert if clicked inside of element + */ + function handleClickInside(event: MouseEvent): void { + if (ref.current && ref.current.contains(event.target as Node)) { + callbackFunction(); + } + } + // Bind the event listener + document.addEventListener("mousedown", handleClickInside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener("mousedown", handleClickInside); + }; + }, [ref]); +} diff --git a/static/package-lock.json b/static/package-lock.json index b77a744ec8..52636581cd 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -150,7 +150,7 @@ "typedoc": "^0.25.4", "vega-embed": "^6.24.0", "vega-lite": "^5.16.3", - "vite": "^5.2.10" + "vite": "^5.2.14" } }, "../packages/web-components": { @@ -26995,7 +26995,7 @@ "typescript": "^5.2.2", "vega-embed": "^6.24.0", "vega-lite": "^5.16.3", - "vite": "^5.2.10" + "vite": "^5.2.14" } }, "@datacommonsorg/web-components": {