From 5b5354d02f87109e1cc30d91d219bde496bcdedf Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Fri, 4 Jun 2021 12:48:56 -0700 Subject: [PATCH] React search component (#19659) * React search component * Update Search.tsx * Update Search.tsx * Standalone mode, start on search with your keyboard * Update Search.tsx * Update Search.tsx * Yay typescript * Update search.js * Update events.js --- components/Header.tsx | 13 +-- components/Search.tsx | 204 ++++++++++++++++++++++++++++++++++++++++++ javascripts/events.js | 39 ++++---- javascripts/search.js | 2 + 4 files changed, 228 insertions(+), 30 deletions(-) create mode 100644 components/Search.tsx diff --git a/components/Header.tsx b/components/Header.tsx index 293aaea922..827630258a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -11,6 +11,7 @@ import { HeaderNotifications } from 'components/HeaderNotifications' import { MobileProductDropdown } from 'components/MobileProductDropdown' import { useTranslation } from 'components/hooks/useTranslation' import { HomepageVersionPicker } from 'components/landing/HomepageVersionPicker' +import { Search } from 'components/Search' export const Header = () => { const router = useRouter() @@ -98,17 +99,7 @@ export const Header = () => { {/* */} - {relativePath !== 'index.md' && error !== '404'} - -
-
- `, - }} - /> + {relativePath !== 'index.md' && error !== '404' && } diff --git a/components/Search.tsx b/components/Search.tsx new file mode 100644 index 0000000000..2c8c2b4d38 --- /dev/null +++ b/components/Search.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/router' +import debounce from 'lodash/debounce' +import { useTranslation } from 'components/hooks/useTranslation' +import { sendEvent } from '../javascripts/events' +import { useMainContext } from './context/MainContext' +import { useVersion } from 'components/hooks/useVersion' + +type SearchResult = { + url: string + breadcrumbs: string + heading: string + title: string + content: string +} + +// Homepage and 404 should be `isStandalone`, all others not +// `updateSearchParams` should be false on the GraphQL explorer page +export function Search({ isStandalone = false, updateSearchParams = true }) { + const [query, setQuery] = useState('') + const [results, setResults] = useState>([]) + const [activeHit, setActiveHit] = useState(0) + const inputRef = useRef(null) + const { t } = useTranslation('search') + const { currentVersion } = useVersion() + + // Figure out language and version for index + const { expose } = useMainContext() + const { searchOptions: { languages, versions, nonEnterpriseDefaultVersion } } = JSON.parse(expose) + const router = useRouter() + // fall back to the non-enterprise default version (FPT currently) on the homepage, 404 page, etc. + const version = versions[currentVersion] || versions[nonEnterpriseDefaultVersion] + const language = languages.includes(router.locale) && router.locale || 'en' + + // If the user shows up with a query in the URL, go ahead and search for it + useEffect(() => { + const params = new URLSearchParams(location.search) + if (params.has('query')) { + const xquery = params.get('query')?.trim() || '' + setQuery(xquery) + /* await */ fetchSearchResults(xquery) + } + return () => setQuery('') + }, []) + + // Search with your keyboard + useEffect(() => { + document.addEventListener('keydown', searchWithYourKeyboard) + return () => document.removeEventListener('keydown', searchWithYourKeyboard) + }, [results, activeHit]) + + function searchWithYourKeyboard (event: KeyboardEvent) { + switch (event.key) { + case '/': + // when the input is focused, `/` should have no special behavior + if ((event.target as HTMLInputElement)?.type === 'search') break + event.preventDefault() // prevent slash from being typed into input + inputRef.current?.focus() + break + case 'Escape': + closeSearch() + break + case 'ArrowDown': + if (!results.length) break + event.preventDefault() // prevent window scrolling + if (activeHit >= results.length) break + setActiveHit(activeHit + 1) + break + case 'ArrowUp': + if (!results.length) break + event.preventDefault() // prevent window scrolling + if (activeHit === 0) break + setActiveHit(activeHit - 1) + break + case 'Enter': + // look for a link in the given hit, then visit it + if (activeHit === 0 || !results.length) break + window.location.href = results[activeHit - 1]?.url + break + } + } + + // When the user finishes typing, update the results + async function onSearch(e: React.ChangeEvent) { + const xquery = e.target?.value?.trim() + setQuery(xquery) + + // Update the URL with the search parameters in the query string + if (updateSearchParams) { + const pushUrl = new URL(location.toString()) + pushUrl.searchParams.set('query', xquery) + history.pushState({}, '', pushUrl.toString()) + } + + // deactivate any active hit when typing in search box + setActiveHit(0) + + return await fetchSearchResults(xquery) + } + + // If there's a query, call the endpoint + // Otherwise, there's no results by default + async function fetchSearchResults (xquery: string) { + if (xquery) { + const endpointUrl = new URL(location.origin) + endpointUrl.pathname = '/search' + const endpointParams: Record = { + language, + version, + query: xquery, + } + endpointUrl.search = new URLSearchParams(endpointParams).toString() + + const response = await fetch(endpointUrl.toString(), { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + setResults(response.ok ? await response.json() : []) + } else { + setResults([]) + } + + // Analytics tracking + if (xquery) { + sendEvent({ + type: 'search', + search_query: xquery + // search_context + }) + } + } + + // Close panel if overlay is clicked + function closeSearch () { + setQuery('') + setResults([]) + } + + // Prevent the page from refreshing when you "submit" the form + function preventRefresh (evt: React.FormEvent) { + evt.preventDefault() + } + + return ( +
+ +
+ {Boolean(results.length) && ( +
+
    + {results.map(({ url, breadcrumbs, heading, title, content }, index) => ( +
  1. +
  2. + ))} +
+
+ )} +
+
+
+ ) +} diff --git a/javascripts/events.js b/javascripts/events.js index 6aa5697fc5..b9b68dc1a7 100644 --- a/javascripts/events.js +++ b/javascripts/events.js @@ -30,25 +30,26 @@ export function getUserEventsId () { export function sendEvent ({ type, version = '1.0.0', - exit_render_duration, - exit_first_paint, - exit_dom_interactive, - exit_dom_complete, - exit_visit_duration, - exit_scroll_length, - link_url, - search_query, - search_context, - navigate_label, - survey_vote, - survey_comment, - survey_email, - experiment_name, - experiment_variation, - experiment_success, - clipboard_operation, - preference_name, - preference_value + // `= undefined` is a TypeScript hint. + exit_render_duration = undefined, + exit_first_paint = undefined, + exit_dom_interactive = undefined, + exit_dom_complete = undefined, + exit_visit_duration = undefined, + exit_scroll_length = undefined, + link_url = undefined, + search_query = undefined, + search_context = undefined, + navigate_label = undefined, + survey_vote = undefined, + survey_comment = undefined, + survey_email = undefined, + experiment_name = undefined, + experiment_variation = undefined, + experiment_success = undefined, + clipboard_operation = undefined, + preference_name = undefined, + preference_value = undefined }) { const body = { _csrf: getCsrf(), diff --git a/javascripts/search.js b/javascripts/search.js index c884173d80..61b8f5ff4d 100644 --- a/javascripts/search.js +++ b/javascripts/search.js @@ -15,6 +15,8 @@ let version let language export default function search () { + if (window.next) return + // We don't want to mess with query params intended for the GraphQL Explorer isExplorerPage = Boolean(document.getElementById('graphiql'))