From 86ef606dad2a8e44b2e21dbad3f8ed69e533ce59 Mon Sep 17 00:00:00 2001 From: andrzej-casper <121791569+andrzej-casper@users.noreply.github.com> Date: Fri, 25 Aug 2023 17:57:03 +0200 Subject: [PATCH] Unified search (#1258) Co-authored-by: Manu Salmeron Co-authored-by: Lucas Arce Co-authored-by: Guido --- src/assets/scss/theme.scss | 28 +++ src/theme/Navbar/Content/index.jsx | 5 +- .../Search/SearchResult/index.tsx | 175 ++++++++++++++---- .../Search/SearchResult/styles.module.scss | 126 ++++++++++++- .../Search/SearchWrapper/index.tsx | 155 ++++++++++++++++ .../Search/SearchWrapper/styles.module.scss | 108 +++++++++++ .../Navbar/ExtendedNavbar/Search/index.tsx | 87 ++++++++- .../Navbar/MobileSidebar/Layout/index.jsx | 10 +- 8 files changed, 635 insertions(+), 59 deletions(-) create mode 100644 src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/index.tsx create mode 100644 src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/styles.module.scss diff --git a/src/assets/scss/theme.scss b/src/assets/scss/theme.scss index 4a358c8700..6e17881a24 100644 --- a/src/assets/scss/theme.scss +++ b/src/assets/scss/theme.scss @@ -296,6 +296,34 @@ html[data-theme="light"] { } } + [class*="SearchWrapper"] { + > input { + background-color: #fff; + + } + span, button { + > svg { + fill: var(--liftedBlack); + path { + fill: var(--liftedBlack); + } + } + } + > p { + background-color: #f4f4f4; + } + > [class*="SearchResult"] { + a { + color: black; + &:hover { + color: var(--casperBlue) !important; + } + small { + color: black !important; + } + } + } + } [class*="container_search"] { --casperYellow: var(--casperBlue); > input { diff --git a/src/theme/Navbar/Content/index.jsx b/src/theme/Navbar/Content/index.jsx index b8c5b0ebf0..d88c4a2fbe 100644 --- a/src/theme/Navbar/Content/index.jsx +++ b/src/theme/Navbar/Content/index.jsx @@ -56,11 +56,12 @@ export default function NavbarContent() { {/* Doc NavBar theme color toggle disabled */} {/* */} - {!searchBarItem && ( + {/* Disabled Search Bar. Unified with the Website Search Bar */} + {/*!searchBarItem && ( - )} + )*/} } /> diff --git a/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/index.tsx b/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/index.tsx index 94da7e7caa..57919d181d 100644 --- a/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/index.tsx +++ b/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/index.tsx @@ -1,17 +1,35 @@ import * as React from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./styles.module.scss"; +import { useEffect, useState } from "react"; +import icons from "../../../../../icons"; interface ISearchResultProps { locale: string; siteUrl: string; hits: any[]; + searchTitle: string; setHasFocus: React.Dispatch>; } -export default function SearchResult({ locale, siteUrl, hits, setHasFocus }: ISearchResultProps) { +export default function SearchResult({ locale, siteUrl, hits, searchTitle, setHasFocus }: ISearchResultProps) { const { siteConfig } = useDocusaurusContext(); const { customFields } = siteConfig; + const [hitsDisplayed, setHitsDisplayed] = useState([]); + const [hideResults, setHideResults] = useState(false); + const portalSearchTitle = "Portal Results"; + + useEffect(() => { + if (hits) { + const slicedHits = hits.slice(0, 4); + if (searchTitle === portalSearchTitle) { + setHitsDisplayed(slicedHits); + } else { + const newGroupHits = groupHits(hits); + setHitsDisplayed(newGroupHits); + } + } + }, [hits]); const getLink = (hit) => { const url = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl; @@ -22,6 +40,38 @@ export default function SearchResult({ locale, siteUrl, hits, setHasFocus }: ISe } }; + function groupHits(hits: any[]) { + const newHits = hits.map((hit) => { + const lastKey = Object.keys(hit._highlightResult?.hierarchy).pop()!; + hit._highlightResult.hierarchy[lastKey].url = hit.url; + return hit._highlightResult.hierarchy; + }); + + const groupedHits: any = []; + let lastHit: any = {}; + newHits.forEach((hit) => { + const hitCopy = JSON.parse(JSON.stringify(hit)); + const hitKeys = Object.keys(hitCopy); + const isValueRepeated = hitKeys.every((key) => hitCopy[key]?.value !== lastHit[key]?.value); + if (isValueRepeated) { + groupedHits.push(hitCopy); + lastHit = hitCopy; + } else { + const keyFound: any = hitKeys.find((key) => { + return hitCopy[key]?.value !== lastHit[key]?.value && key !== "lvl0"; + }); + const lastHitFound = groupedHits[groupedHits.length - 1][keyFound]; + + if (Array.isArray(lastHitFound)) { + groupedHits[groupedHits.length - 1][keyFound].push(hitCopy[keyFound]); + } else { + groupedHits[groupedHits.length - 1][keyFound] = lastHitFound ? [lastHitFound, hitCopy[keyFound]] : [hitCopy[keyFound]]; + } + } + }); + return groupedHits; + } + function handleContentHit(contentHit: string) { const previousWordsToShow = 4; const markOpenIndex = contentHit.indexOf(""); @@ -39,56 +89,113 @@ export default function SearchResult({ locale, siteUrl, hits, setHasFocus }: ISe } function highlight(hit: any) { - if (hit._highlightResult?.title?.matchedWords?.length > 0) - return ; - else if (hit._highlightResult?.internal?.content?.matchedWords?.length > 0) + if (hit._highlightResult?.title?.matchedWords?.length > 0) { + return ; + } else if (hit._highlightResult?.internal?.content?.matchedWords?.length > 0) { return ( - {hit._highlightResult?.title?.value} +

{hit._highlightResult?.title?.value}

); + } else { + return {hit._highlightResult?.title?.value}; + } + } + + function renderLinkOrTitle(element: any, key: string) { + if (element?.url) { + return ; + } else if (key === "lvl0") { + return
; + } + } + function highlightDoc(hit: any) { + let elemArr = []; + for (const key in hit) { + if (Array.isArray(hit[key])) { + hit[key].forEach((element: any) => { + elemArr.push(renderLinkOrTitle(element, key)); + }); + } else { + elemArr.push(renderLinkOrTitle(hit[key], key)); + } + } + return elemArr; + } + + function loadMoreHits(e: any) { + e.stopPropagation(); + setHitsDisplayed(hits); + } + + function hiddenResults(): void { + setHideResults((current) => !current); } return ( <> -
setHasFocus(false)}> - {hits && hits.length > 0 ? ( - hits.map((hit: any, i: number) => { - if (hit._highlightResult?.title?.matchedWords?.length > 0 || hit._highlightResult?.internal?.content?.matchedWords?.length > 0) { - return ( - -
- +

hiddenResults()}> + {searchTitle} {icons.chevronDown} +

+
setHasFocus(false)}> + {hits ? ( + hits.length > 0 ? ( + hitsDisplayed.map((hit: any, i: number) => { + if (hit._highlightResult?.title?.matchedWords?.length > 0 || hit._highlightResult?.internal?.content?.matchedWords?.length > 0) { + return ( + +
+ + + + {highlight(hit)} +
+ + + +
+ ); + } else if (hit.lvl0) { + return ( +
+
+ + + +
{highlightDoc(hit)?.map((parsedHit) => parsedHit)}
+
+ - {highlight(hit)}
- - - - - ); - } - }) + ); + } + }) + ) : ( + No results found + ) ) : ( - No results found +
)}
+ {hits && hits.length > 4 && hits.length !== hitsDisplayed.length && ( + + )} ); } -/* -<> - {hits.map((hit, i) => ( - - ))} -; */ diff --git a/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/styles.module.scss b/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/styles.module.scss index 1a64d69368..6c8aa3b39a 100644 --- a/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/styles.module.scss +++ b/src/theme/Navbar/ExtendedNavbar/Search/SearchResult/styles.module.scss @@ -1,19 +1,37 @@ @use "../../../../../assets/scss/mixins"; -.results_container { - position: absolute; - width: 100%; +.results_portal_title { background-color: var(--liftedBlack); - border: 1px solid rgba(255, 255, 255, 0.25); + margin-bottom: 0; + padding: 10px 20px; + display: flex; + justify-content: space-between; + align-items: center; + height: 32px; + cursor: pointer; + + svg { + pointer-events: none; + path { + fill: var(--casperWhite); + } + } + > p { + margin: 0px; + } +} +.results_container { + background-color: var(--black); border-radius: 2px; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; gap: 27px; - padding: 20px 15px; - max-height: 381px; + padding: 10px 15px; + max-height: 265px; overflow-y: scroll; + transition: all 500ms; @include mixins.custom_scrollbar(var(--black) var(--casperWhite)); @@ -25,8 +43,23 @@ width: 100%; color: #ffffff; text-decoration: none; - max-height: 50px; - + cursor: pointer; + + span, + p { + overflow: hidden; + text-overflow: ellipsis; + width: 90%; + } + a { + margin-left: 5px; + color: var(--casperWhite); + font-size: 14px; + + &:hover { + color: var(--casperYellow); + } + } svg { path { @@ -49,10 +82,15 @@ display: flex; flex-direction: column; gap: 3px; - width: 100%; + width: 80%; justify-content: flex-start; overflow: hidden; text-overflow: ellipsis; + font-size: 16px; + p { + font-weight: 500; + margin-bottom: 0px; + } > small { max-width: 100%; @@ -61,17 +99,85 @@ color: #c3c3c3; } } + .docElement { + width: 80%; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + display: flex; + justify-content: center; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: #c3c3c3; + + > :first-child { + font-weight: 500; + cursor: default; + color: var(--casperWhite); + font-size: 16px; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + &:hover { + @include mixins.transition(0.3s all); + color: var(--casperYellow); + } + } + } + em { color: var(--casperYellow); background-color: transparent; font-style: normal; } + + :global { + .algolia-docsearch-suggestion--highlight { + color: var(--casperYellow); + } + } } } } +.showMore { + width: 100%; + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: 45px; + padding-bottom: 6px; + padding-top: 6px; + background-color: var(--black); + border: none; + &:hover { + text-decoration: underline; + } +} + +.centerSpinner { + margin: auto; +} + +.hiddenResults { + transition: all 500ms; + max-height: 0px; + padding: 0px 15px; + width: 100%; + overflow-x: hidden; +} + +.rotateSvg { + transform: rotate(180deg); +} + +.hitWeighTitle { + font-weight: 500; +} + @media (max-width: 996px) { .results_container { z-index: 6; } -} \ No newline at end of file +} diff --git a/src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/index.tsx b/src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/index.tsx new file mode 100644 index 0000000000..0263b1df0d --- /dev/null +++ b/src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/index.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useRef, useState } from "react"; +import useEventListener from "../../../../../hooks/useEventListener"; +import icons from "../../../../../icons"; +import SearchResult from "../SearchResult"; +import useClickOutside from "../UseClickOutside"; +import styles from "./styles.module.scss"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +interface ISearchWrapperProps { + searchIndexes: any[]; + locale: string; + siteUrl: string; + placeholder: string; + hitsPerIndex: number; +} + +export default function SearchWrapper({ searchIndexes, locale, siteUrl, placeholder, hitsPerIndex = 20 }: ISearchWrapperProps) { + const { siteConfig } = useDocusaurusContext(); + const [searchTerm, setSearchTerm] = useState(""); + const refInput = useRef(null); + const [hasFocus, setHasFocus] = useState(false); + const [showResults, setShowResults] = useState(false); + const [hits, setHits] = useState({}); + const docsIndexName = (siteConfig.themeConfig.algolia?.indexName as string) ?? "casperlabs"; + const siteIndexName = (siteConfig.customFields.siteAlgoliaIndexName as string) ?? "casper"; + + let delayDebounceFn: NodeJS.Timeout; + + const handleKeyClose = (e: KeyboardEvent): void => { + if (e.key === "Escape") resetState(); + }; + + function resetState() { + setSearchTerm(""); + clearInput(); + } + + function triggerSearchIndexes(val: string) { + let promiseArr = []; + + for (let index of searchIndexes) { + promiseArr.push( + index.client.search(val, { + hitsPerPage: index.client.indexName === docsIndexName ? 4 : hitsPerIndex, + }), + ); + } + + Promise.allSettled(promiseArr) + .then((results: any) => { + const hits = {}; + for (let i = 0; i < results.length; i++) { + let parsedHits: any[] = []; + const basePath = searchIndexes[i].base; + const result = results[i]; + if (result.status === "fulfilled") { + let parsedRes = result.value.hits; + + parsedRes = parsedRes.map((element: any) => { + return { ...element, basePath: basePath, path: basePath ? basePath + element.path : element.path }; + }); + + parsedHits = [...parsedHits, ...parsedRes]; + hits[`${searchIndexes[i].client.indexName}`] = parsedHits; + } else { + console.log(`${result.reason.name} ${result.reason.message}`); + } + } + setHits(hits); + }) + .catch((err) => console.log(err)); + } + + function clearSearch() { + setHits([]); + } + + function handleChangeSearchTerm(e: React.ChangeEvent) { + clearTimeout(delayDebounceFn); + delayDebounceFn = setTimeout(() => { + setShowResults(false); + const value = e.target.value; + setSearchTerm(value); + if (value) { + triggerSearchIndexes(value); + } else { + clearSearch(); + } + }, 500); + } + + useEffect(() => { + // -- Only true if search term has a value + // -- Avoid to show the empty results + setShowResults(searchTerm || hits.length > 0 ? true : false); + }, [searchTerm, hits]); + + useEventListener("keydown", handleKeyClose); + + useClickOutside(refInput, (isInside: boolean) => setHasFocus(isInside)); + + function clearInput() { + const buttons = document.getElementsByClassName(styles.container_input); + for (const button of buttons) { + (button as HTMLInputElement).value = ""; + } + setSearchTerm(""); + setShowResults(false); + } + + return ( +
setHasFocus(true)}> + <> + + {icons.search} + {searchTerm && ( + + )} + + {hasFocus && showResults && ( + <> +
+ + + {hits[docsIndexName] && hits[docsIndexName].length > 0 && ( + + )} +
+ + )} +
+ ); +} diff --git a/src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/styles.module.scss b/src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/styles.module.scss new file mode 100644 index 0000000000..473d436df4 --- /dev/null +++ b/src/theme/Navbar/ExtendedNavbar/Search/SearchWrapper/styles.module.scss @@ -0,0 +1,108 @@ +@use "../../../../../assets/scss/mixins"; + +.container { + position: relative; + margin: 0; + width: 25%; + min-width: 15%; + + .results_wrapper { + position: absolute; + width: 100%; + border: 1px solid var(--casperYellow); + z-index: 6; + @include mixins.transition(0.3s all); + min-width: 375px; + right: 0; + } + + .search_link { + background-color: var(--black); + border-top: 1px solid var(--casperWhite); + cursor: pointer; + display: flex; + a { + padding: 10px; + width: 100%; + height: 100%; + color: var(--casperYellow); + background-color: transparent; + font-style: normal; + text-decoration: none; + } + &:hover { + background-color: var(--casperYellow); + a { + color: var(--black); + } + @include mixins.transition(0.3s all); + } + } + + .container_icon_search { + position: absolute; + left: 10px; + top: 5px; + bottom: 0; + width: 20px; + } + + .container_icon_cancel { + position: absolute; + right: 5px; + top: 4px; + background-color: transparent; + border: 0; + } + + .container_input { + width: 100%; + background-color: var(--liftedBlack); + color: var(--casperWhite); + + background-size: 22px; + background-position: 9px 9px; + background-repeat: no-repeat; + padding: 0 40px 0 40px; + height: 36px; + border: 1px solid var(--casperWhite); + border-radius: 2px; + white-space: nowrap; + + &::placeholder { + opacity: 0.7; + color: var(--casperWhite); + } + + &:focus { + color: var(--casperYellow); + border: 1px solid var(--casperYellow); + + outline: none; + + + .container_icon_search { + svg { + path { + fill: var(--casperYellow); + } + } + } + + + .container_icon_search + .container_icon_cancel { + &:hover { + svg { + path { + fill: var(--casperYellow); + } + } + } + } + } + } +} + +@media (max-width: 996px) { + .container { + width: 100%; + } +} diff --git a/src/theme/Navbar/ExtendedNavbar/Search/index.tsx b/src/theme/Navbar/ExtendedNavbar/Search/index.tsx index 38a66fd444..9610ad5a86 100644 --- a/src/theme/Navbar/ExtendedNavbar/Search/index.tsx +++ b/src/theme/Navbar/ExtendedNavbar/Search/index.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import algoliasearch from "algoliasearch/lite"; +import algoliasearch, { SearchClient } from "algoliasearch/lite"; +import SearchWrapper from "./SearchWrapper"; import { Configure, InstantSearch } from "react-instantsearch-hooks-web"; import SearchBox from "./SearchBox"; @@ -16,16 +17,84 @@ interface ISearchProps { export default function Search({ index, placeholder, locale, siteUrl }: ISearchProps) { const { siteConfig } = useDocusaurusContext(); + let indexesArray: any[] = []; - const searchClient = useMemo( - () => algoliasearch(siteConfig.customFields.siteAlgoliaAppId as string, siteConfig.customFields.siteAlgoliaApiKey as string), - [], - ); + if (siteConfig.themeConfig.algolia?.indexName && siteConfig.themeConfig.algolia?.appId && siteConfig.themeConfig.algolia?.apiKey) { + const algoliaDocClient = useMemo( + () => algoliasearch(siteConfig.themeConfig.algolia?.appId as string, siteConfig.themeConfig.algolia?.apiKey as string), + [], + ); + + const searchDocClient: SearchClient = { + ...algoliaDocClient, + search(requests) { + // -- Return default response in case that the query is empty + if (requests.every(({ params }) => !params || !params.query)) { + return Promise.resolve({ + results: requests.map(() => ({ + hits: [], + nbHits: 0, + nbPages: 0, + page: 0, + processingTimeMS: 0, + hitsPerPage: 0, + exhaustiveNbHits: false, + query: "", + params: "", + })), + }); + } + + return algoliaDocClient.search(requests); + }, + }; + + const docIndex = { + base: null, + client: searchDocClient.initIndex((siteConfig.themeConfig.algolia?.indexName as string) ?? "casperlabs"), + }; + indexesArray.push(docIndex); + } + let searchAppClient: SearchClient; + if (siteConfig.customFields.siteAlgoliaIndexName && siteConfig.customFields.siteAlgoliaAppId && siteConfig.customFields.siteAlgoliaApiKey) { + const algoliaAppClient = useMemo( + () => algoliasearch(siteConfig.customFields.siteAlgoliaAppId as string, siteConfig.customFields.siteAlgoliaApiKey as string), + [], + ); + searchAppClient = { + ...algoliaAppClient, + search(requests) { + // -- Return default response in case that the query is empty + if (requests.every(({ params }) => !params || !params.query)) { + return Promise.resolve({ + results: requests.map(() => ({ + hits: [], + nbHits: 0, + nbPages: 0, + page: 0, + processingTimeMS: 0, + hitsPerPage: 0, + exhaustiveNbHits: false, + query: "", + params: "", + })), + }); + } + + return algoliaAppClient.search(requests); + }, + }; + const appIndex = { + base: null, + client: searchAppClient.initIndex((siteConfig.customFields.siteAlgoliaIndexName as string) ?? "casper"), + }; + + indexesArray.push(appIndex); + } return ( - - - - + <> + + ); } diff --git a/src/theme/Navbar/MobileSidebar/Layout/index.jsx b/src/theme/Navbar/MobileSidebar/Layout/index.jsx index 02564ad1b1..f0b3b2f3d2 100644 --- a/src/theme/Navbar/MobileSidebar/Layout/index.jsx +++ b/src/theme/Navbar/MobileSidebar/Layout/index.jsx @@ -53,13 +53,15 @@ export default function NavbarMobileSidebarLayout({ header, primaryMenu, seconda
{primaryMenu}
{secondaryMenu}
-
- {!searchBarItem && ( + + {/* Disabled Search Bar. Unified with the Website Search Bar */} + {/*
+ !searchBarItem && ( - )} -
+ ) +
*/}
); }