Files
outline/app/scenes/Search/Search.tsx
Tom Moor 958cf45d74 Support for filter by parent document (#6850)
* Backend support for filter by parent document

* parentDocumentId -> documentId
2024-04-25 19:44:15 -07:00

369 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { observer } from "mobx-react";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hideScrollbars } from "@shared/styles";
import {
DateFilter as TDateFilter,
StatusFilter as TStatusFilter,
} from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import env from "~/env";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import { SearchResult } from "~/types";
import { searchPath } from "~/utils/routeHelpers";
import { decodeURIComponentSafe } from "~/utils/urls";
import CollectionFilter from "./components/CollectionFilter";
import DateFilter from "./components/DateFilter";
import { DocumentFilter } from "./components/DocumentFilter";
import DocumentTypeFilter from "./components/DocumentTypeFilter";
import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import UserFilter from "./components/UserFilter";
type Props = { notFound?: boolean };
function Search(props: Props) {
const { t } = useTranslation();
const { documents, searches } = useStores();
// routing
const params = useQuery();
const location = useLocation();
const history = useHistory();
const routeMatch = useRouteMatch<{ term: string }>();
// refs
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
const resultListCompositeRef = React.useRef<HTMLDivElement | null>(null);
const recentSearchesCompositeRef = React.useRef<HTMLDivElement | null>(null);
// filters
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const documentId = params.get("documentId") ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
const statusFilter = params.getAll("statusFilter")?.length
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
const titleFilter = params.get("titleFilter") === "true";
const hasFilters = !!(documentId || collectionId || userId || dateFilter);
const filters = React.useMemo(
() => ({
query,
statusFilter,
collectionId,
userId,
dateFilter,
titleFilter,
documentId,
}),
[
query,
JSON.stringify(statusFilter),
collectionId,
userId,
dateFilter,
titleFilter,
documentId,
]
);
const requestFn = React.useMemo(() => {
// Add to the searches store so this search can immediately appear in the recent searches list
// without a flash of loading.
if (query) {
searches.add({
id: uuidv4(),
query,
createdAt: new Date().toISOString(),
});
return async () =>
titleFilter
? await documents.searchTitles(query, filters)
: await documents.search(query, filters);
}
return () => Promise.resolve([] as SearchResult[]);
}, [query, titleFilter, filters, searches, documents]);
const { data, next, end, loading } = usePaginatedRequest(requestFn, {
limit: Pagination.defaultLimit,
});
const document = documentId ? documents.get(documentId) : undefined;
const updateLocation = (query: string) => {
history.replace({
pathname: searchPath(query),
search: location.search,
});
};
// All filters go through the query string so that searches are bookmarkable, which neccesitates
// some complexity as the query string is the source of truth for the filters.
const handleFilterChange = (search: {
collectionId?: string | undefined;
documentId?: string | undefined;
userId?: string | undefined;
dateFilter?: TDateFilter;
statusFilter?: TStatusFilter[];
titleFilter?: boolean | undefined;
}) => {
history.replace({
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), ...search },
{
skipEmptyString: true,
}
),
});
};
const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
updateLocation(ev.currentTarget.value);
return;
}
if (ev.key === "Escape") {
ev.preventDefault();
return history.goBack();
}
if (ev.key === "ArrowUp") {
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionEnd = ev.currentTarget.selectionEnd || 0;
if (selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = length;
ev.preventDefault();
}
}
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstResultItem = (
resultListCompositeRef.current?.querySelectorAll(
"[href]"
) as NodeListOf<HTMLAnchorElement>
)?.[0];
const firstRecentSearchItem = (
recentSearchesCompositeRef.current?.querySelectorAll(
"li > [href]"
) as NodeListOf<HTMLAnchorElement>
)?.[0];
const firstItem = firstResultItem ?? firstRecentSearchItem;
firstItem?.focus();
}
};
const handleEscape = () => searchInputRef.current?.focus();
const showEmpty = !loading && query && data?.length === 0;
return (
<Scene textTitle={query ? `${query} ${t("Search")}` : t("Search")}>
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
{loading && <LoadingIndicator />}
{props.notFound && (
<div>
<h1>{t("Not Found")}</h1>
<Empty>
{t("We were unable to find the page youre looking for.")}
</Empty>
</div>
)}
<ResultsWrapper column auto>
<SearchInput
key={query ? "search" : "recent"}
ref={searchInputRef}
placeholder={`${
documentId
? t("Search in document")
: collectionId
? t("Search in collection")
: t("Search")
}`}
onKeyDown={handleKeyDown}
defaultValue={query}
/>
{(query || hasFilters) && (
<Filters>
{document && (
<DocumentFilter
document={document}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) => handleFilterChange({ collectionId })}
/>
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
handleFilterChange({ titleFilter: ev.target.checked });
}}
checked={titleFilter}
/>
</Filters>
)}
{query ? (
<>
{showEmpty && (
<Fade>
<Centered column>
<Text as="p" type="secondary">
{t("No documents found for your search filters.")}
</Text>
</Centered>
</Fade>
)}
<ResultList column>
<StyledArrowKeyNavigation
ref={resultListCompositeRef}
onEscape={handleEscape}
aria-label={t("Search Results")}
>
{(compositeProps) =>
data?.length
? data.map((result) => (
<DocumentListItem
key={result.document.id}
document={result.document}
highlight={query}
context={result.context}
showCollection
showTemplate
{...compositeProps}
/>
))
: null
}
</StyledArrowKeyNavigation>
<Waypoint
key={data?.length}
onEnter={end || loading ? undefined : next}
debug={env.ENVIRONMENT === "development"}
/>
</ResultList>
</>
) : documentId || collectionId ? null : (
<RecentSearches
ref={recentSearchesCompositeRef}
onEscape={handleEscape}
/>
)}
</ResultsWrapper>
</Scene>
);
}
const Centered = styled(Flex)`
text-align: center;
margin: 30vh auto 0;
max-width: 380px;
transform: translateY(-50%);
`;
const ResultsWrapper = styled(Flex)`
${breakpoint("tablet")`
margin-top: 40px;
`};
`;
const ResultList = styled(Flex)`
margin-bottom: 150px;
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
display: flex;
flex-direction: column;
flex: 1;
`;
const Filters = styled(Flex)`
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
overflow-y: hidden;
overflow-x: auto;
padding: 8px 0;
gap: 8px;
${hideScrollbars()}
${breakpoint("tablet")`
padding: 0;
`};
&: ${hover} {
opacity: 1;
}
`;
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 2px;
font-size: 14px;
font-weight: 400;
`;
export default observer(Search);