Skip to content

Commit

Permalink
feat: stub in All tab for unified search behind flag (#1986)
Browse files Browse the repository at this point in the history
* chore: add search-test page

* feat: WIP set up All tab for unified search

* chore: enable unified search in previews

* chore: ensure unified search is disabled in prod

* chore: start cleaning up all tab

* chore: continue cleanup

* chore: continue refactor

* refactor: un-abstract all-tab-contents

* chore: split out getShouldRenderIntegrationsTab

* chore: fix typo

* refactor: add back hit count with filter split

* chore: get no results message working

* chore: revert not-needed change to hit-counts-provider

* chore: revert unhelpful rename

* chore: enable unified search in development

* chore: split out utils, continue cleanup

* chore: split out use-debounced-recent-searches

* chore: clean up imports a little

* chore: useMemo for integrations tab bool

* docs: add comment on unified-hit work

* chore: cleanup to co-locate helpers with related code

* chore: continue helper co-location

* chore: remove unused export

* docs: add comment on build-url-path

* chore: add magenta border to ugly-first-draft card

* chore: update comment typo

* chore: add console error for build-url-path issue

* chore: turn off search flags

* chore: clarify use of edition filter

* chore: enable dark mode on hit placeholder
  • Loading branch information
zchsh authored Jun 16, 2023
1 parent 33f32b0 commit 0263405
Show file tree
Hide file tree
Showing 21 changed files with 816 additions and 9 deletions.
3 changes: 2 additions & 1 deletion config/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"algolia": {
"docsIndexName": "product_DEVDOT",
"tutorialsIndexName": "prod_LEARN"
"tutorialsIndexName": "prod_LEARN",
"unifiedIndexName": "staging_DEVDOT_omni"
},
"analytics": {
"included_domains": "developer.hashi-mktg.com developer.hashicorp.com"
Expand Down
3 changes: 3 additions & 0 deletions config/preview.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"extends": "base",
"io_sites": {
"max_static_paths": 10
},
"flags": {
"enable_unified_search": false
}
}
3 changes: 3 additions & 0 deletions config/production.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@
"idp_url": "https://auth.idp.hashicorp.com"
},
"product_slugs_with_integrations": ["vault", "waypoint", "nomad"]
},
"flags": {
"enable_unified_search": false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
*/

import { ReactElement } from 'react'
import { SearchableContentType } from 'contexts'
import { CurrentContentType } from 'contexts'
import Text from 'components/text'
import { useCommandBar } from 'components/command-bar'
import s from './no-results-message.module.css'

interface NoResultsMessageProps {
tabsWithResults: {
type: SearchableContentType
type: CurrentContentType
heading: string
icon: ReactElement<React.JSX.IntrinsicElements['svg']>
}[]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.suggestedPagesWrapper {
padding: 16px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ProductSlug } from 'types/products'

/**
* Given an optional product slug,
*
* Return an Algolia `filter` string that will filter for search objects
* of any type (`docs`, `tutorial`, or `integration`) that match the
* specified product slug.
*
* Note: intended for use with our unified search indices, which are
* named `<env>_DEVDOT_omni` in Algolia.
*/
export function getAlgoliaProductFilterString(
productSlug?: ProductSlug
): string {
let filterString = ''

if (productSlug) {
filterString = `products:${productSlug}`

/**
* The edition:hcp only applies to `tutorials` records, which will
* never have products:hcp, but we can't apply complex filters
* via the Algolia filters API parameter to only apply the `edition`
* filter to tutorial records, so we use an OR filter instead.
* Ref: https://www.algolia.com/doc/api-reference/api-parameters/filters/
*/
if (productSlug === 'hcp') {
filterString += ` OR edition:${productSlug}`
}
}

return filterString
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './get-algolia-product-filter-string'
export * from './use-command-bar-product-tag'
export * from './use-debounced-recent-searches'
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useCallback, useMemo } from 'react'
import { useCommandBar } from 'components/command-bar'
import { useCurrentProduct } from 'contexts'
import { getCurrentProductTag } from '../../../../helpers'
import { useSetUpAndCleanUpCommandState } from 'components/command-bar/hooks'

/**
* Set and un-set the command-bar-level product filtering tag.
*
* TODO: consider refactoring this.
*
* We've had feedback from design at least that the "product tag" interface
* doesn't feel right within the search modal. This raises a few questions:
*
* - Is this genuinely a command-bar-wide setting, or are "product tag" filters
* something we could apply to the "Search" command only for now? (Note:
* I'm not even sure other "commands" (ie "settings") are implemented yet...
* managing the "tags" at the command bar level feels like it may have
* been a premature abstraction based on relatively speculative use cases).
*
* - In the context of search, specifically auto-filtering to a given product
* when in that product context, Would typing-based filters, such as
* `product:<productSlug>`, be preferable to the "product tag" approach?
*/
export function useCommandBarProductTag() {
const currentProduct = useCurrentProduct()
const { addTag, currentTags, removeTag } = useCommandBar()

/**
* Create callback for setting up this command's state.
*/
const setUpCommandState = useCallback(() => {
if (currentProduct) {
addTag({
id: currentProduct.slug,
text: currentProduct.slug === 'hcp' ? 'HCP' : currentProduct.name,
})
}
}, [addTag, currentProduct])

/**
* Create callback for cleaning up this command's state.
*/
const cleanUpCommandState = useCallback(() => {
if (currentProduct) {
removeTag(currentProduct.slug)
}
}, [currentProduct, removeTag])

/**
* Leveraging the set up + clean up hook exposed by CommandBarDialog.
*/
useSetUpAndCleanUpCommandState(setUpCommandState, cleanUpCommandState)

/**
* Get the CommandBarTag object for the current product if it's present.
*/
const currentProductTag = useMemo(
() =>
getCurrentProductTag({
currentProduct,
currentTags,
}),
[currentProduct, currentTags]
)

return currentProductTag
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import useRecentSearches from '../../../../hooks/use-recent-searches'
// Types
import type { Dispatch, SetStateAction } from 'react'

/**
* Debounce a setState function to the providing millisecond timing.
*
* Used as a temporary measure to retain debouncing of "recent search" query
* strings. Later, we may want to consider a different debounce method.
*/
export function useSetStateDebounce<T>(
setStateFn: Dispatch<SetStateAction<T>>,
stateValue: T,
timing: number
) {
useEffect(() => {
const typingDebounce = setTimeout(() => {
setStateFn(stateValue)
}, timing)
return () => clearTimeout(typingDebounce)
}, [stateValue, timing, setStateFn])
}

/**
* Given the current input value,
*
* Save recent search input values to local storage with the `useRecentSearches`
* hook, while debouncing input value changes to prevent every keystroke
* from being saves.
*
* TODO: consider longer debounce for setting recent search queries?
* Or maybe additional debounce should be built into `useRecentSearches`?
*
* Maybe the debounce should be part of some variation of the
* `useRecentSearches` hook instead of how things currently are?
*/
export function useDebouncedRecentSearches(currentInputValue: string) {
const [debouncedInput, setDebouncedInput] = useState<string>(undefined)
const { recentSearches, addRecentSearch } = useRecentSearches()

/**
* Delay recording search queries while the user is typing
*/
useSetStateDebounce(setDebouncedInput, currentInputValue, 300)

/**
* Add a new "recent search" when the debounced input value updates.
*/
useEffect(
() => addRecentSearch(debouncedInput),
[addRecentSearch, debouncedInput]
)

return recentSearches
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,90 @@
* SPDX-License-Identifier: MPL-2.0
*/

function UnifiedSearchCommandBarDialogBody() {
import { useMemo } from 'react'
// Libraries
import algoliasearch from 'algoliasearch'
import { Configure, InstantSearch } from 'react-instantsearch-hooks-web'
// Command bar
import { useCommandBar } from 'components/command-bar'
// Shared search
import { generateSuggestedPages } from '../../../helpers'
import { RecentSearches, SuggestedPages } from '../../../components'
// Unified search
import { UnifiedHitsContainer } from '../unified-hits-container'
import {
getAlgoliaProductFilterString,
useCommandBarProductTag,
useDebouncedRecentSearches,
} from './helpers'
// Types
import type { ProductSlug } from 'types/products'
import type { SuggestedPage } from '../../../components'
// Styles
import s from './dialog-body.module.css'

/**
* Initialize the algolia search client.
*
* TODO(brkalow): We might consider lazy-loading the search client & the insights library
*/
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_API_KEY
const searchClient = algoliasearch(appId, apiKey)

/**
* Render the command bar dialog body for unified search results.
*
* If we have an input value in the command bar, we render search results
* from Algolia, through the `UnifiedHitsContainer` component. We apply
* an initial filter for the current product context where applicable.
*
* If we don't have an input value, we render suggested pages, which are
* tailored to the current product context where applicable.
*
* This component also tracks recent searches through `useRecentSearches`.
*/
export function UnifiedSearchCommandBarDialogBody() {
const { currentInputValue } = useCommandBar()
const currentProductTag = useCommandBarProductTag()
const currentProductSlug = currentProductTag?.id as ProductSlug
const recentSearches = useDebouncedRecentSearches(currentInputValue)

/**
* Generate suggested pages for the current product (if any).
*/
const suggestedPages = useMemo<SuggestedPage[]>(() => {
return generateSuggestedPages(currentProductSlug)
}, [currentProductSlug])

/**
* If there's no searchQuery yet, show suggested pages.
*/
if (!currentInputValue) {
return (
<div className={s.suggestedPagesWrapper}>
<RecentSearches recentSearches={recentSearches} />
<SuggestedPages pages={suggestedPages} />
</div>
)
}

/**
* Render search results based on the current input value.
*/
return (
<div style={{ border: '2px solid magenta' }}>
Unified search results pane will go here.
</div>
<InstantSearch
indexName={__config.dev_dot.algolia.unifiedIndexName}
searchClient={searchClient}
>
<Configure
query={currentInputValue}
filters={getAlgoliaProductFilterString(currentProductSlug)}
/>
<UnifiedHitsContainer
currentProductSlug={currentProductSlug}
suggestedPages={suggestedPages}
/>
</InstantSearch>
)
}

export { UnifiedSearchCommandBarDialogBody }
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { getTutorialSlug } from 'views/collection-view/helpers'
import { getIntegrationUrl } from 'lib/integrations'
// Types
import type { Hit } from 'instantsearch.js'

/**
* Builds a URL path to an arbitrary hit from our unified `<env>_DEVDOT_omni`
* Algolia indices.
*
* TODO: consider baking a `urlPath` property into each Algolia object?
*/
export function buildUrlPath(searchHit: Hit): string {
if (searchHit.type === 'docs') {
const objectIdWithoutType = searchHit.objectID.replace('docs_', '')
return `/${objectIdWithoutType}`.replace(/\/index$/, '')
} else if (searchHit.type === 'tutorial') {
const { slug, defaultContext } = searchHit
return getTutorialSlug(slug, defaultContext.slug)
} else if (searchHit.type === 'integration') {
const {
external_only,
external_url,
product_slug,
organization_slug,
slug,
} = searchHit
/**
* TODO: refactor `getIntegrationUrl` to `Pick<>` what's actually needed.
* For now, using `$TSFixMe` to avoid inaccurate TS errors about what
* argument properties are needed for the function to work.
*/
return getIntegrationUrl({
external_only,
external_url,
product: { slug: product_slug } as $TSFixMe,
organization: { slug: organization_slug } as $TSFixMe,
slug,
} as $TSFixMe)
} else {
/**
* Something's gone wrong, this should never happen in our indexing.
* Link to the home page as insurance. And ideally, should log
* an error here, not just locally, but maybe elsewhere?
*
* TODO: figure out where to log errors like this in such a way that
* they're surfaced to the team, rather than only appearing in-browser.
*/
console.error(
`Unexpected input in build-url-path: content type "${searchHit.type}" is not a recognized content type. Valid content types are "docs", "tutorial", and "integration". Please ensure the object pushed to Algolia only use these content types.`
)
return '/'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './build-url-path'
export * from './render-highlight-array-html'
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Placeholder function to render some highlighted information.
*
* We'll replace this with something properly usable in a future pass
* to the "All" tab work for unified search.
*/
export function renderHighlightArrayHtml(facet, matchesOnly = false) {
return (facet || [])
.filter((entry) => {
if (matchesOnly) {
return entry.matchLevel !== 'none'
} else {
return true
}
})
.map((entry) => {
return entry?.value
})
.join(', ')
}
Loading

1 comment on commit 0263405

@vercel
Copy link

@vercel vercel bot commented on 0263405 Jun 16, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.