Skip to content
This repository has been archived by the owner on Oct 20, 2022. It is now read-only.

Add search endpoint, and initial search bar implementation #315

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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.
Expand Down
9 changes: 9 additions & 0 deletions components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ 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;
};

export default function Header({ docsType = "haystack" }: Props) {
const [darkMode, setDarkMode] = useState(false);
const [searchModal, setSearchModal] = useState(false);

const handleChange = () => {
if (localStorage.theme === undefined) {
Expand Down Expand Up @@ -118,6 +121,12 @@ export default function Header({ docsType = "haystack" }: Props) {
</Link>
</div>
</div>
<div className="hidden lg:flex mr-8 xl:mr-12 2xl:mr-16">
<button className="" onClick={() => setSearchModal(true)}>
<SearchIcon />
</button>
<SearchModal searchModal={searchModal} setSearchModal={setSearchModal} />
</div>
<div className="hidden lg:flex items-center mr-8 xl:mr-12 2xl:mr-16">
<Switch
checked={darkMode}
Expand Down
7 changes: 7 additions & 0 deletions components/SearchIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function SearchIcon() {
return (
<svg width="28" height="28" viewBox="0 0 20 20">
<path d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z" stroke="white" strokeWidth={1.5} fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
)
}
36 changes: 36 additions & 0 deletions components/SearchModal.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
277 changes: 277 additions & 0 deletions components/SearchModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
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 {
groupName: string;
documentName: string;
navPath: string;
}

interface IGroupedSearchResult {
name: string;
values: IProcessedSearchResult[];
}

function useDebounce<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState<T>(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 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[2]] = [segments[1], segments[0], replaceWhitespaceLink(segments[2])];
const path = removeMdxFileEnding(segments.join('/')).replace('latest/', '');
return `${window.location.origin}/${path}`;
}

const getNavNames = (result: ISearchResult): [string, 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;
}

const parseSearchResults = (results: ISearchResult[]): IProcessedSearchResult[] => {
return results.map((result) => {
const [groupName, documentName] = getNavNames(result);
return {
...result,
groupName,
documentName,
navPath: getNavPath(result),
}
})
}

const groupResults = (results: IProcessedSearchResult[]): IGroupedSearchResult[] => {
const groups = results.reduce((pv: any, cv) => {
return {
...pv,
[cv.groupName]: [
...(pv[cv.groupName] || []),
cv,
]
}
}, {});
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 (
<span className="px-2 py-1 mb-1 rounded-full bg-black/20 text-gray-400 text-center text-sm">{props.version}</span>
)
}

export function SearchResult(props: { result: IProcessedSearchResult }) {
const previewMaxLength = 250;
return (
<>
<a href={props.result.navPath}>
<div className="flex flex-col px-6 py-3 border-t border-gray-100/20">
<div className="flex flex-row flex-grow justify-between">
<span className="text-lg font-bold">{props.result.documentName}</span>
<VersionPill version={props.result.meta.docs_version} />
</div>
<span>{props.result.content.substring(0, previewMaxLength)}...</span>
</div>
</a>
</>
)
}

export function SearchResultGroup(props: { group: IGroupedSearchResult }) {
return (
<>
<div className="flex flex-col bg-blue-900/30 text-gray-100 rounded-md py-2">
<span className="text-xl font-bold text-gray-100 mb-2 px-5 py-2">{props.group.name}</span>
{
props.group.values.map(result => (
<SearchResult key={result.result_id} result={result} />
))
}
</div>
</>
)
}

export function Spinner() {
return (
<>
<div className={styles['loading-spinner']}>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</>
)
}

export function SearchModal(props: {
searchModal: boolean,
setSearchModal: Dispatch<SetStateAction<boolean>>,
}) {
const [searchResults, setSearchResults] = useState<IGroupedSearchResult[]>([]);
const [searchTerm, setSearchTerm] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isDirty, setIsDirty] = useState<boolean>(false);
const debouncedSearchTerm = useDebounce<string>(searchTerm, 400);

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[];
}
setIsLoading(true);
const data = await search(searchTerm);
setSearchResults(
sortGroups(
groupResults(
parseSearchResults(data.sort((a,b) => isLatestVersion(b) - isLatestVersion(a)))
)
)
);
setIsDirty(true);
setIsLoading(false);
};

const closeModal = () => {
props.setSearchModal(false);
setSearchTerm("");
setSearchResults([]);
setIsDirty(false);
}

const inputKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
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();

useEffect(() => {
if (debouncedSearchTerm !== '') search();
}, [debouncedSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps

return (
<>
{
props.searchModal && (
<div className="relative z-10">
<div className="fixed inset-0 bg-gray-800 bg-opacity-75 transition-all"></div>
<div className="fixed z-10 inset-0 overflow-y-auto">
<div
className="flex items-center justify-center min-h-screen"
onClick={closeModal}
>
<div
className="relative inline-block bg-dark-blue rounded-lg transition-all w-[80vw] max-h-[80vh] overflow-y-auto no-scrollbar drop-shadow-xl shadow-inner-light"
onClick={(e => e.stopPropagation())}
>
<div className={`flex flex-row justify-center items-center h-20 px-5 ${showContent() ? 'border-b' : ''} border-white/20`}>
<SearchIcon />
<input
id="search-input"
autoFocus
className="h-full flex-grow px-5 no-focus-border bg-transparent text-xl text-white/90"
onChange={(e) => {
setSearchTerm(e.target.value);
setSearchResults([]);
setIsDirty(false);
}
}
type="text"
placeholder="Search documentation"
onKeyDown={inputKeyPress}
/>
{
isLoading && <Spinner />
}
</div>

<div className={`px-10 ${showContent() ? 'pb-10' : ''}`}>
{
showNoResults() && (
<>
<div className="flex flex-row justify-center mt-12 text-2xl">
<span className="text-gray-400">No results for &quot;<span className="text-gray-300">{searchTerm}</span>&quot;</span>
</div>
</>
)
}
{
showResults() && (
<>
<ul className="mt-8 overflow-y-auto">
{
searchResults.map(group => (
<li className="mb-4" key={group.name}>
<SearchResultGroup group={group} />
</li>
))
}
</ul>
</>
)
}
</div>

</div>
</div>
</div>
</div>
)
}
</>
)
}
Loading