diff --git a/src/app/page.tsx b/src/app/page.tsx index 953b6dff..2c61ce1a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,9 +3,11 @@ import Image from "next/image"; import logo from "../../public/sb_logo_large_3.png" import { Input } from "@/components/ui/input" -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDebouncedCallback } from 'use-debounce'; import { Separator } from "@/components/ui/separator" +import { useRouter } from "next/navigation"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; interface ZoekMatch { URL: string, @@ -35,27 +37,23 @@ interface ZoekSearchResult { export default function Home() { + const router = useRouter(); + const defaultQuery = useNonEmptyQueryParam("query") ?? ""; + const defaultNumResults = useNonEmptyQueryParam("numResults"); + + const [query, setQuery] = useState(defaultQuery); + const [numResults, _setNumResults] = useState(defaultNumResults && !isNaN(Number(defaultNumResults)) ? Number(defaultNumResults) : 100); const [fileMatches, setFileMatches] = useState([]); - const onSearchChanged = useDebouncedCallback((query: string) => { - if (query === "") { - setFileMatches([]); - return; - } - console.log('making query...'); - fetch(`${document.baseURI}/zoekt/search?query=${query}&numResults=50`) - .then(response => response.json()) - .then(({ data }: { data: ZoekSearchResult }) => { - const result = data.result; - setFileMatches(result.FileMatches ?? []); - }) - .catch(error => { - console.error('Error:', error); - }).finally(() => { - console.log('done making query'); - }) - }, 200); + /** + * @note : when the user navigates backwards/forwards, the defaultQuery + * will update, but the query state will not. This effect keeps things in + * sync for that scenario. + */ + useEffect(() => { + setQuery(defaultQuery); + }, [defaultQuery]); return (
@@ -65,12 +63,14 @@ export default function Home() { className="h-12 w-auto" alt={"Sourcebot logo"} /> - { - const query = e.target.value; - onSearchChanged(query); + setQuery(query)} + onClear={() => setFileMatches([])} + onSearchResult={({ result }) => { + setFileMatches(result.FileMatches ?? []); + router.push(`?query=${query}&numResults=${numResults}`); }} /> @@ -87,6 +87,59 @@ export default function Home() { ); } +interface SearchBarProps { + query: string; + numResults: number; + onQueryChange: (query: string) => void; + onSearchResult: (result: ZoekSearchResult) => void, + onClear: () => void, +} + +const SearchBar = ({ + query, + numResults, + onQueryChange, + onSearchResult, + onClear, +}: SearchBarProps) => { + const SEARCH_DEBOUNCE_MS = 200; + + const search = useDebouncedCallback((query: string) => { + if (query === "") { + onClear(); + return; + } + console.log('making query...'); + fetch(`http://localhost:3000/zoekt/search?query=${query}&numResults=${numResults}`) + .then(response => response.json()) + .then(({ data }: { data: ZoekSearchResult }) => { + onSearchResult(data); + }) + // @todo : error handling + .catch(error => { + console.error('Error:', error); + }).finally(() => { + console.log('done making query'); + }) + }, SEARCH_DEBOUNCE_MS); + + useEffect(() => { + search(query); + }, [query]); + + return ( + { + const query = e.target.value; + onQueryChange(query); + }} + /> + ) +} + interface FileMatchProps { match: ZoekFileMatch; } diff --git a/src/app/zoekt/search/route.ts b/src/app/zoekt/search/route.ts index 2763a889..3c5cfa11 100644 --- a/src/app/zoekt/search/route.ts +++ b/src/app/zoekt/search/route.ts @@ -1,3 +1,4 @@ +import { createPathWithQueryParams } from '@/lib/utils'; import { type NextRequest } from 'next/server' export async function GET(request: NextRequest) { @@ -5,7 +6,14 @@ export async function GET(request: NextRequest) { const query = searchParams.get('query'); const numResults = searchParams.get('numResults'); - const res = await fetch(`http://localhost:6070/search?q=${query}&num=${numResults}&format=json`); + const url = createPathWithQueryParams( + "http://localhost:6070/search", + ["q", query], + ["num", numResults], + ["format", "json"], + ); + console.log(url); + const res = await fetch(url); const data = await res.json(); return Response.json({ data }) diff --git a/src/hooks/useNonEmptyQueryParam.ts b/src/hooks/useNonEmptyQueryParam.ts new file mode 100644 index 00000000..d3a20417 --- /dev/null +++ b/src/hooks/useNonEmptyQueryParam.ts @@ -0,0 +1,33 @@ +'use client'; + +import { useSearchParams } from "next/navigation"; +import { useMemo } from "react"; + +/** + * Helper hook that returns the value of a query parameter if it is: + * a) defined, and + * b) non-empty + * + * otherwise it returns undefined. + * + * For example, let's assume we are calling `useNonEmptyQueryParam('bar')`: + * - `/foo?bar=hello` -> `hello` + * - `/foo?bar=` -> `undefined` + * - `/foo` -> `undefined` + */ +export const useNonEmptyQueryParam = (param: string) => { + const searchParams = useSearchParams(); + const inviteId = useMemo(() => { + return getSearchParam(param, searchParams); + }, [param, searchParams]); + + return inviteId; +}; + +/** + * @see useNonEmptyQueryParam + */ +export const getSearchParam = (param: string, searchParams: URLSearchParams | null) => { + const paramValue = searchParams?.get(param) ?? undefined; + return (paramValue !== undefined && paramValue.length > 0) ? paramValue : undefined; +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d084ccad..d16afd4b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,23 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Adds a list of (potentially undefined) query parameters to a path. + * + * @param path The path to add the query parameters to. + * @param queryParams A list of key-value pairs (key=param name, value=param value) to add to the path. + * @returns The path with the query parameters added. + */ +export const createPathWithQueryParams = (path: string, ...queryParams: [string, string | null][]) => { + // Filter out undefined values + // eslint-disable-next-line @typescript-eslint/no-unused-vars + queryParams = queryParams.filter(([_key, value]) => value !== null); + + if (queryParams.length === 0) { + return path; + } + + const queryString = queryParams.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value ?? '')}`).join('&'); + return `${path}?${queryString}`; +} \ No newline at end of file