import React from "react"; import { createSearchParams, Link, useSearchParams } from "react-router-dom"; import useSWR from "swr"; import { Loading } from "../ui/atoms/loading"; import { WRITER_MODE, KUMA_HOST } from "../env"; import { useLocale } from "../hooks"; import { appendURL } from "./utils"; import { Button } from "../ui/atoms/button"; import "./search-results.scss"; import NoteCard from "../ui/molecules/notecards"; import LANGUAGES_RAW from "../../../libs/languages"; const LANGUAGES = new Map( Object.entries(LANGUAGES_RAW).map(([locale, data]) => { return [locale.toLowerCase(), data]; }) ); const SORT_OPTIONS = [ ["best", "Best"], ["relevance", "Relevance"], ["popularity", "Popularity"], ]; type Highlight = { body?: string[]; title?: string[]; }; interface Document { mdn_url: string; locale: string; title: string; highlight: Highlight; summary: string; score: number; popularity: number; } type Total = { value: number; relation: "eq" | "gt"; }; interface Metadata { // The time it took Elasticsearch query took_ms: number; // The time it took Kuma's API to wrap the Elasticsearch query api_took_ms: number; total: Total; } interface Suggestion { text: string; total: Total; } interface FormErrorMessage { message: string; code: string; } type FormErrors = [{ key: string }, FormErrorMessage[]]; class BadRequestError extends Error { public formErrors: FormErrors; constructor(formErrors: FormErrors) { super(`BadRequestError ${JSON.stringify(formErrors)}`); this.formErrors = formErrors; } } class ServerOperationalError extends Error { public statusCode: number; constructor(statusCode: number) { super(`ServerOperationalError ${statusCode}`); this.statusCode = statusCode; } } export default function SearchResults() { const [searchParams] = useSearchParams(); const locale = useLocale(); // A call to `/api/v1/search` will default to mean the same thing as // a call to `/api/v1/search?locale=en-us`. But if they page you're currently // on, (e.g. `/ja/search`) we should supply a more explicit default. const sp = createSearchParams(searchParams); if (!searchParams.getAll("locale").length) { // In other words, if it's not explicitly set by the current query string, // force in the locale based on the current URL (path). sp.set("locale", locale); } const fetchURL = `/api/v1/search?${sp.toString()}`; const { data, error } = useSWR( fetchURL, async (url) => { const response = await fetch(url); if (response.status === 400) { const badRequest = await response.json(); throw new BadRequestError(badRequest.errors); } else if (response.status >= 500) { throw new ServerOperationalError(response.status); } else if (!response.ok) { throw new Error(`${response.status} on ${url}`); } return await response.json(); }, { revalidateOnFocus: process.env.NODE_ENV === "development", } ); const page = searchParams.get("page"); const [initialPage, setInitialPage] = React.useState(page); React.useEffect(() => { if (page !== initialPage) { setInitialPage(page); const resultsElement = document.querySelector("div.site-search"); if (resultsElement) { resultsElement.scrollIntoView({ behavior: "smooth" }); } } }, [page, initialPage]); if (error) { if (error instanceof BadRequestError) { return ( ); } if (error instanceof ServerOperationalError) { return ( ); } return (

Something else went horribly wrong with the search

{error.toString()}

); } if (data) { const currentPage = data.metadata.page; const pageSize = data.metadata.size; const hitCount = data.metadata.total.value; return ( <> {/* It only makes sense to display the sorting options if anything was found */} {hitCount > 1 && } ); } // else... return ; } function RemoteSearchWarning() { if (WRITER_MODE) { // If you're in WRITER_MODE, the search results will be proxied from a remote // Kuma and it might be confusing if a writer is wondering why their // actively worked-on content isn't showing up in searches. // The default value in the server is not accessible from the react app, // so it's hardcoded here in the client. const kumaHost = KUMA_HOST; return (

Note!

Site-search is proxied to {kumaHost} which means that some content found doesn't reflect what's in your current branch.

); } return null; } function SortOptions() { const [searchParams] = useSearchParams(); const querySort = searchParams.get("sort") || SORT_OPTIONS[0][0]; return (

Sort by:

    {SORT_OPTIONS.map(([key, label], i) => { return (
  • {key === querySort ? ( label ) : ( {label} )}
  • ); })}
); } function SearchErrorContainer({ children }: { children: React.ReactNode }) { return (

Search error

{children}
); } function ExplainBadRequestError({ errors }: { errors: FormErrors }) { return (

The search didn't work because there were problems with the input.

    {Object.keys(errors).map((key) => { const messages: FormErrorMessage[] = errors[key]; return (
  • {key}{" "} {messages.map((message) => { return {message.message}; })}
  • ); })}
); } function ExplainServerOperationalError({ statusCode }: { statusCode: number }) { return (

The search failed because the server failed to respond.

If you're curious, it was a {statusCode} error.

); } function Results({ documents, metadata, suggestions, }: { documents: Document[]; metadata: Metadata; suggestions: Suggestion[]; }) { const locale = useLocale(); const [searchParams] = useSearchParams(); return (

Found in {metadata.took_ms}{" "} milliseconds.

{!!suggestions.length && (

Did you mean...

    {suggestions.map((suggestion) => { return (
  • {suggestion.text} {" "}
  • ); })}
)}
); } function ShowTotal({ total }: { total: Total }) { return ( <> {total.relation === "gt" && "more than"} {total.value.toLocaleString()}{" "} {total.value === 1 ? "match" : "matches"} ); } function Pagination({ currentPage, maxPage, hitCount, pageSize, }: { currentPage: number; maxPage: number; hitCount: number; pageSize: number; }) { const [searchParams] = useSearchParams(); if (!hitCount) { return null; } if (hitCount <= pageSize) { return null; } let previousPage: number | null = null; let nextPage: number | null = null; if (hitCount > currentPage * pageSize && currentPage < maxPage) { nextPage = currentPage + 1; } if (currentPage > 1) { previousPage = currentPage - 1; } if (nextPage || previousPage !== null) { let previousURL = ""; if (previousPage) { if (previousPage === 1) { previousURL = `?${appendURL(searchParams, { page: undefined, })}`; } else { previousURL = `?${appendURL(searchParams, { page: `${previousPage}`, })}`; } } return (
    {previousURL ? (
  • Previous
  • ) : null}{" "} {nextPage ? (
  • Next
  • ) : null}
); } return null; }