From b3ed19824ab43dea277794f8872ae1738f6d5135 Mon Sep 17 00:00:00 2001 From: Friedrich Staufenbiel Date: Fri, 13 May 2022 07:52:35 +0200 Subject: [PATCH 1/5] Add search endpoint, and initial search bar implementation --- README.md | 8 ++ components/Header.tsx | 9 ++ components/SearchIcon.tsx | 7 ++ components/SearchModal.tsx | 174 +++++++++++++++++++++++++++++++++++++ pages/api/search.ts | 72 +++++++++++++++ styles/global.css | 10 +++ 6 files changed, 280 insertions(+) create mode 100644 components/SearchIcon.tsx create mode 100644 components/SearchModal.tsx create mode 100644 pages/api/search.ts diff --git a/README.md b/README.md index 2cb227276..c47b1752d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ Create a `.env.local` file and add your token as an env variable: GITHUB_PERSONAL_ACCESS_TOKEN="youraccesstoken" ``` +To use the documentation search, provide the search endpoint and access token in `.env.local`. + +```bash +HAYSTACK_ENDPOINT="" +HAYSTACK_TOKEN="" +``` + + ### Required Reading This project makes heavy use of Next.js's [getStaticProps](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) and [getStaticPaths](https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation) functions, to fetch markdown files at build time (locally from the `docs` directory as well as from GitHub using the GitHub API) and generate html pages for each of these files. Before working on the project, it's vital that you understand how these functions work and how they apply to this project. [This example](https://github.com/vercel/next.js/tree/canary/examples/blog-starter-typescript) and [this example](https://github.com/vercel/next.js/tree/canary/examples/with-mdx-remote) may be used as simple demonstrations of these functions to solidify your understanding. diff --git a/components/Header.tsx b/components/Header.tsx index c161453d3..035ce18e2 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -3,6 +3,8 @@ import VersionSelect from "./VersionSelect"; import { useState, useEffect } from "react"; import { Switch } from "@headlessui/react"; import { SunIcon, MoonIcon } from "@heroicons/react/outline"; +import { SearchModal } from "./SearchModal"; +import { SearchIcon } from "./SearchIcon"; type Props = { docsType: string; @@ -10,6 +12,7 @@ type Props = { export default function Header({ docsType = "haystack" }: Props) { const [darkMode, setDarkMode] = useState(false); + const [searchModal, setSearchModal] = useState(false); const handleChange = () => { if (localStorage.theme === undefined) { @@ -118,6 +121,12 @@ export default function Header({ docsType = "haystack" }: Props) { +
+ + +
+ + + ) +} diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx new file mode 100644 index 000000000..82d406257 --- /dev/null +++ b/components/SearchModal.tsx @@ -0,0 +1,174 @@ +import { Dispatch, KeyboardEvent, SetStateAction, useState } from "react"; +import { ISearchResult } from "pages/api/search"; +import { SearchIcon } from "./SearchIcon"; + + +const replaceWhitespaceLink = (text: string): string => text.replace('_', '-'); +const replaceWhitespaceText = (text: string): string => text.replace('_', ' '); +const replaceUnderscore = (text: string): string => text.replace('_', '-'); +const removeMdxFileEnding = (text: string): string => text.replace('.mdx', ''); +const capitalizeWords = (text: string): string => text.replace(/(?:^|\s)\S/g, (a) => { return a.toUpperCase(); }); + +const cleanPathSegment = (segment: string): string => { + return removeMdxFileEnding( + replaceWhitespaceText( + replaceUnderscore(segment) + ) + ); +} + +const getNavPath = (result: ISearchResult): string => { + const segments = result.meta.filepath.split('/'); + [segments[0], segments[1]] = [segments[1], segments[0]]; + const path = removeMdxFileEnding( + replaceUnderscore( + replaceWhitespaceLink(segments.join('/')) + ) + ).replace('latest/', ''); + return `${window.location.origin}/${path}`; +} + +const getNavName = (result: ISearchResult): string => { + const segments = result.meta.filepath.split('/'); + return capitalizeWords(cleanPathSegment(segments[1])) + " - " + capitalizeWords(cleanPathSegment(segments[2])); +} + +const isLatestVersion = (result: ISearchResult): number => { + return result.meta.docs_version === 'latest' ? 1 : 0; +} + +export function VersionPill(props: { version: string }) { + return ( + {props.version} + ) +} + +export function SearchResult(props: { result: ISearchResult }) { + return ( + <> + +
+
+ {getNavName(props.result)} + +
+ {props.result.content} +
+
+ + ) +} + +export function SearchModal(props: { + searchModal: boolean, + setSearchModal: Dispatch>, +}) { + const [searchResults, setSearchResults] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [isDirty, setIsDirty] = useState(false); + + const search = async () => { + if (searchTerm === "") return; + const search = async (term: string) => { + const result = await fetch(`${window.location.origin}/api/search`, { + method: 'POST', + body: JSON.stringify({ term }), + }); + if (result.status != 200) return [] as ISearchResult[]; + const data = await result.json(); + + return data as ISearchResult[]; + } + const data = await search(searchTerm); + setSearchResults(data.sort((a,b) => isLatestVersion(b) - isLatestVersion(a))); + setIsDirty(true); + }; + + const closeModal = () => { + props.setSearchModal(false); + setSearchTerm(""); + setSearchResults([]); + setIsDirty(false); + } + + const inputKeyPress = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': search(); break; + case 'Escape': closeModal(); break; + case 'Esc': closeModal(); break; + } + } + + const showNoResults = () => searchResults.length === 0 && isDirty; + const showResults = () => searchResults.length > 0; + const showContent = () => showNoResults() || showResults(); + + return ( + <> + { + props.searchModal && ( +
+
+
+
+
e.stopPropagation())} + > +
+ + { + setSearchTerm(e.target.value); + setSearchResults([]); + setIsDirty(false); + } + } + type="text" + placeholder="Search documentation" + onKeyDown={inputKeyPress} + /> +
+ +
+ { + showNoResults() && ( + <> +
+ No results for "{searchTerm}" +
+ + ) + } + { + showResults() && ( + <> +
    + { + searchResults.map(result => ( +
  • + +
  • + )) + } +
+ + ) + } +
+ +
+
+
+
+ ) + } + + ) +} diff --git a/pages/api/search.ts b/pages/api/search.ts new file mode 100644 index 000000000..a4e2c117d --- /dev/null +++ b/pages/api/search.ts @@ -0,0 +1,72 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +export interface ISearchResultFile { + [key: string]: string; +} + +export interface ISearchResultMeta { + [key: string]: string; + filepath: string; + docs_version: string; +} + +export interface ISearchResult { + content: string; + content_type: string; + file: ISearchResultFile; + meta: ISearchResultMeta; + result_id: string; + score: number; +} + +export interface ISearchRequest { + term: string; +} + +export interface ISearchErrorResponse { + message: string; +} + +class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +const validateSearchRequest = (requestBody: string): ISearchRequest => { + const data = JSON.parse(requestBody); + Object.keys(data).forEach(key => { + if (key != 'term') throw new ValidationError(`Provided illegal key: ${key}`); + }); + return data as ISearchRequest; +} + +const searchEndpoint = process.env.HAYSTACK_ENDPOINT || ""; +const searchToken = process.env.HAYSTACK_TOKEN || ""; +const searchHeaders = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${searchToken}`, +}; + +const search = async (term: string) => { + const result = await fetch(searchEndpoint, { + method: 'POST', + headers: searchHeaders, + body: JSON.stringify({ "queries": [ term ] }), + }); + const data = await result.json(); + return data.results[0].documents as ISearchResult[]; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const request = validateSearchRequest(req.body); + const data = await search(request.term); + return res.status(200).json(data); + } catch (e) { + if (e instanceof ValidationError) return res.status(400).json({ message: e.message }) + return res.status(500).json({ message: 'unexpected error' }); + } +} diff --git a/styles/global.css b/styles/global.css index ec21fc4a2..68f8776f9 100644 --- a/styles/global.css +++ b/styles/global.css @@ -102,4 +102,14 @@ font-style: italic; src: url("/fonts/greycliff-CF/woff/thin-oblique.woff") format("woff"); } + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + input.no-focus-border:focus { + outline-width: 0; + } } From a6bb4306fb1c0c522df3af2a77723f5c98f2f3b4 Mon Sep 17 00:00:00 2001 From: Friedrich Staufenbiel Date: Mon, 30 May 2022 15:11:08 +0200 Subject: [PATCH 2/5] Add inner shadow to search bar --- components/SearchModal.tsx | 2 +- tailwind.config.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 82d406257..19728a73e 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -115,7 +115,7 @@ export function SearchModal(props: { onClick={closeModal} >
e.stopPropagation())} >
diff --git a/tailwind.config.js b/tailwind.config.js index b4076ca16..56622bda3 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -30,6 +30,9 @@ module.exports = { fontFamily: { sans: ["Greycliff CF", ...defaultTheme.fontFamily.sans], }, + boxShadow: { + "inner-light": "inset 0 0 0 1px rgb(255 255 255 / 20%)" + } }, }, variants: { From f427d279b5162907ff64b32c618306e91a87cd3d Mon Sep 17 00:00:00 2001 From: Friedrich Staufenbiel Date: Mon, 30 May 2022 18:12:38 +0200 Subject: [PATCH 3/5] Debounce search call --- components/SearchModal.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 19728a73e..c06d0f854 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -1,4 +1,4 @@ -import { Dispatch, KeyboardEvent, SetStateAction, useState } from "react"; +import { Dispatch, KeyboardEvent, SetStateAction, useEffect, useState } from "react"; import { ISearchResult } from "pages/api/search"; import { SearchIcon } from "./SearchIcon"; @@ -43,6 +43,19 @@ export function VersionPill(props: { version: string }) { ) } +function useDebounce(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + } + }, [value, delay]); + return debouncedValue; +} + export function SearchResult(props: { result: ISearchResult }) { return ( <> @@ -66,6 +79,7 @@ export function SearchModal(props: { const [searchResults, setSearchResults] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [isDirty, setIsDirty] = useState(false); + const debouncedSearchTerm = useDebounce(searchTerm, 400); const search = async () => { if (searchTerm === "") return; @@ -103,6 +117,10 @@ export function SearchModal(props: { const showResults = () => searchResults.length > 0; const showContent = () => showNoResults() || showResults(); + useEffect(() => { + if (debouncedSearchTerm !== '') search(); + }, [debouncedSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps + return ( <> { From 0bdf4a65906e5eeb4010d002af256ba7320de8da Mon Sep 17 00:00:00 2001 From: Friedrich Staufenbiel Date: Tue, 31 May 2022 08:54:32 +0200 Subject: [PATCH 4/5] Group search results by category, sort groups by highest match --- components/SearchModal.tsx | 127 ++++++++++++++++++++++++++++--------- 1 file changed, 98 insertions(+), 29 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index c06d0f854..7d4fb52c2 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -3,9 +3,33 @@ import { ISearchResult } from "pages/api/search"; import { SearchIcon } from "./SearchIcon"; -const replaceWhitespaceLink = (text: string): string => text.replace('_', '-'); +interface IProcessedSearchResult extends ISearchResult { + groupName: string; + documentName: string; + navPath: string; +} + +interface IGroupedSearchResult { + name: string; + values: IProcessedSearchResult[]; +} + +function useDebounce(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + } + }, [value, delay]); + return debouncedValue; +} + +const replaceWhitespaceLink = (text: string): string => text.replace('_', '-').replace(' ', '-'); const replaceWhitespaceText = (text: string): string => text.replace('_', ' '); -const replaceUnderscore = (text: string): string => text.replace('_', '-'); +const replaceUnderscore = (text: string): string => text.replace('_', ' '); const removeMdxFileEnding = (text: string): string => text.replace('.mdx', ''); const capitalizeWords = (text: string): string => text.replace(/(?:^|\s)\S/g, (a) => { return a.toUpperCase(); }); @@ -28,55 +52,94 @@ const getNavPath = (result: ISearchResult): string => { return `${window.location.origin}/${path}`; } -const getNavName = (result: ISearchResult): string => { +const getNavNames = (result: ISearchResult): [string, string] => { const segments = result.meta.filepath.split('/'); - return capitalizeWords(cleanPathSegment(segments[1])) + " - " + capitalizeWords(cleanPathSegment(segments[2])); + return [capitalizeWords(cleanPathSegment(segments[1])), capitalizeWords(cleanPathSegment(segments[2]))]; } const isLatestVersion = (result: ISearchResult): number => { return result.meta.docs_version === 'latest' ? 1 : 0; } -export function VersionPill(props: { version: string }) { - return ( - {props.version} - ) +const parseSearchResults = (results: ISearchResult[]): IProcessedSearchResult[] => { + return results.map((result) => { + const [groupName, documentName] = getNavNames(result); + return { + ...result, + groupName, + documentName, + navPath: getNavPath(result), + } + }) } -function useDebounce(value: T, delay: number) { - const [debouncedValue, setDebouncedValue] = useState(value); - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - return () => { - clearTimeout(handler); +const groupResults = (results: IProcessedSearchResult[]): IGroupedSearchResult[] => { + const groups = results.reduce((pv: any, cv) => { + return { + ...pv, + [cv.groupName]: [ + ...(pv[cv.groupName] || []), + cv, + ] } - }, [value, delay]); - return debouncedValue; + }, {}); + return Object.keys(groups).map(groupKey => ({ + name: groupKey, + values: groups[groupKey], + })) +} + +const sortGroups = (results: IGroupedSearchResult[]): IGroupedSearchResult[] => { + const scored = results.map(result => ({ + ...result, + maxScore: result.values.reduce((a, b) => Math.max(a, b.score), 0), + })); + return scored.sort((a, b) => b.maxScore - a.maxScore); +} + +export function VersionPill(props: { version: string }) { + return ( + {props.version} + ) } -export function SearchResult(props: { result: ISearchResult }) { +export function SearchResult(props: { result: IProcessedSearchResult }) { + const previewMaxLength = 250; return ( <> - -
+ +
- {getNavName(props.result)} + {props.result.documentName}
- {props.result.content} + {props.result.content.substring(0, previewMaxLength)}...
) } +export function SearchResultGroup(props: { group: IGroupedSearchResult }) { + return ( + <> +
+ {props.group.name} + { + props.group.values.map(result => ( + + )) + } +
+ + ) +} + export function SearchModal(props: { searchModal: boolean, setSearchModal: Dispatch>, }) { - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [isDirty, setIsDirty] = useState(false); const debouncedSearchTerm = useDebounce(searchTerm, 400); @@ -94,7 +157,13 @@ export function SearchModal(props: { return data as ISearchResult[]; } const data = await search(searchTerm); - setSearchResults(data.sort((a,b) => isLatestVersion(b) - isLatestVersion(a))); + setSearchResults( + sortGroups( + groupResults( + parseSearchResults(data.sort((a,b) => isLatestVersion(b) - isLatestVersion(a))) + ) + ) + ); setIsDirty(true); }; @@ -167,11 +236,11 @@ export function SearchModal(props: { { showResults() && ( <> -
    +
      { - searchResults.map(result => ( -
    • - + searchResults.map(group => ( +
    • +
    • )) } From e13864795baa0d4b7de5ba24a8fb50b4583d1f7d Mon Sep 17 00:00:00 2001 From: Friedrich Staufenbiel Date: Tue, 31 May 2022 17:50:48 +0200 Subject: [PATCH 5/5] Add loading indicator, adjust link generation handling in search --- components/SearchModal.module.css | 36 +++++++++++++++++++++++++++++++ components/SearchModal.tsx | 28 ++++++++++++++++++------ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 components/SearchModal.module.css diff --git a/components/SearchModal.module.css b/components/SearchModal.module.css new file mode 100644 index 000000000..c8085e582 --- /dev/null +++ b/components/SearchModal.module.css @@ -0,0 +1,36 @@ +.loading-spinner { + display: inline-block; + position: relative; + width: 50px; + height: 50px; +} + +.loading-spinner div { + box-sizing: border-box; + display: block; + position: absolute; + width: 40px; + height: 40px; + margin: 4px; + border: 4px solid #fff; + border-radius: 50%; + animation: loading-spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + border-color: #fff transparent transparent transparent; +} +.loading-spinner div:nth-child(1) { + animation-delay: -0.45s; +} +.loading-spinner div:nth-child(2) { + animation-delay: -0.3s; +} +.loading-spinner div:nth-child(3) { + animation-delay: -0.15s; +} +@keyframes loading-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 7d4fb52c2..93089bdf7 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -1,6 +1,7 @@ import { Dispatch, KeyboardEvent, SetStateAction, useEffect, useState } from "react"; import { ISearchResult } from "pages/api/search"; import { SearchIcon } from "./SearchIcon"; +import styles from "./SearchModal.module.css"; interface IProcessedSearchResult extends ISearchResult { @@ -43,12 +44,8 @@ const cleanPathSegment = (segment: string): string => { const getNavPath = (result: ISearchResult): string => { const segments = result.meta.filepath.split('/'); - [segments[0], segments[1]] = [segments[1], segments[0]]; - const path = removeMdxFileEnding( - replaceUnderscore( - replaceWhitespaceLink(segments.join('/')) - ) - ).replace('latest/', ''); + [segments[0], segments[1], segments[2]] = [segments[1], segments[0], replaceWhitespaceLink(segments[2])]; + const path = removeMdxFileEnding(segments.join('/')).replace('latest/', ''); return `${window.location.origin}/${path}`; } @@ -135,12 +132,26 @@ export function SearchResultGroup(props: { group: IGroupedSearchResult }) { ) } +export function Spinner() { + return ( + <> +
      +
      +
      +
      +
      +
      + + ) +} + export function SearchModal(props: { searchModal: boolean, setSearchModal: Dispatch>, }) { const [searchResults, setSearchResults] = useState([]); const [searchTerm, setSearchTerm] = useState(""); + const [isLoading, setIsLoading] = useState(false); const [isDirty, setIsDirty] = useState(false); const debouncedSearchTerm = useDebounce(searchTerm, 400); @@ -156,6 +167,7 @@ export function SearchModal(props: { return data as ISearchResult[]; } + setIsLoading(true); const data = await search(searchTerm); setSearchResults( sortGroups( @@ -165,6 +177,7 @@ export function SearchModal(props: { ) ); setIsDirty(true); + setIsLoading(false); }; const closeModal = () => { @@ -221,6 +234,9 @@ export function SearchModal(props: { placeholder="Search documentation" onKeyDown={inputKeyPress} /> + { + isLoading && + }