Convert Search page to functional component (#6268)

This commit is contained in:
Tom Moor
2023-12-09 21:54:39 -05:00
committed by GitHub
parent 3f3d7b4978
commit 5dfa6a6011
7 changed files with 224 additions and 376 deletions

View File

@@ -15,7 +15,6 @@ import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
@@ -104,7 +103,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const results = await documents.searchTitles(term);
return sortBy(
results.map((document: Document) => ({
results.map(({ document }) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,

View File

@@ -116,7 +116,7 @@ const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 18px;
height: 22px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
@@ -138,7 +138,7 @@ const ResultContext = styled(Highlight)`
color: ${s("textTertiary")};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
margin-bottom: 0;
${ellipsis()}
${Mark} {

View File

@@ -31,9 +31,11 @@ function SearchPopover({ shareId }: Props) {
});
const [query, setQuery] = React.useState("");
const searchResults = documents.searchResults(query);
const { show, hide } = popover;
const [searchResults, setSearchResults] = React.useState<
PaginatedItem[] | undefined
>();
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
PaginatedItem[] | undefined
@@ -50,7 +52,16 @@ function SearchPopover({ shareId }: Props) {
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
return await documents.search(query, { shareId, ...options });
const response: PaginatedItem[] = await documents.search(query, {
shareId,
...options,
});
if (response.length) {
setSearchResults(response);
}
return response;
}
return undefined;
},

View File

@@ -25,7 +25,7 @@ import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
subtitle?: string;
subtitle?: React.ReactNode;
url: string;
};

View File

@@ -14,6 +14,8 @@ type RequestResponse<T> = {
next: () => void;
/** Page number */
page: number;
/** Offset */
offset: number;
/** Marks the end of pagination */
end: boolean;
};
@@ -64,9 +66,7 @@ export default function usePaginatedRequest<T = unknown>(
uniqBy((prev ?? []).concat(response.slice(0, displayLimit)), "id")
);
setPage((prev) => prev + 1);
if (response.length <= displayLimit) {
setEnd(true);
}
setEnd(response.length <= displayLimit);
}
}, [response, displayLimit, loading]);
@@ -87,5 +87,12 @@ export default function usePaginatedRequest<T = unknown>(
setOffset((prev) => prev + displayLimit);
}, [displayLimit]);
return { data, next, loading, error, page, end };
React.useEffect(() => {
setEnd(false);
setData(undefined);
setPage(0);
setOffset(0);
}, [requestFn]);
return { data, next, loading, error, page, offset, end };
}

View File

@@ -1,10 +1,8 @@
import isEqual from "lodash/isEqual";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import queryString from "query-string";
import * as React from "react";
import { WithTranslation, withTranslation, Trans } from "react-i18next";
import { RouteComponentProps, StaticContext, withRouter } from "react-router";
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";
@@ -12,8 +10,6 @@ import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hideScrollbars } from "@shared/styles";
import { DateFilter as TDateFilter } from "@shared/types";
import { SearchParams } from "~/stores/DocumentsStore";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty";
@@ -24,11 +20,13 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import withStores from "~/components/withStores";
import env from "~/env";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import Logger from "~/utils/Logger";
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 RecentSearches from "./components/RecentSearches";
@@ -36,73 +34,102 @@ import SearchInput from "./components/SearchInput";
import StatusFilter from "./components/StatusFilter";
import UserFilter from "./components/UserFilter";
type Props = RouteComponentProps<
{ term: string },
StaticContext,
{ search: string; fromMenu?: boolean }
> &
WithTranslation &
RootStore & {
notFound?: boolean;
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 = routeMatch.params.term ?? "";
const includeArchived = params.get("includeArchived") === "true";
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
const titleFilter = params.get("titleFilter") === "true";
const filters = React.useMemo(
() => ({
query,
includeArchived,
collectionId,
userId,
dateFilter,
titleFilter,
}),
[query, includeArchived, collectionId, userId, dateFilter, titleFilter]
);
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 updateLocation = (query: string) => {
history.replace({
pathname: searchPath(query),
search: location.search,
});
};
@observer
class Search extends React.Component<Props> {
resultListCompositeRef: HTMLDivElement | null | undefined;
recentSearchesCompositeRef: HTMLDivElement | null | undefined;
searchInputRef: HTMLInputElement | null | undefined;
lastQuery = "";
lastParams: SearchParams;
@observable
query: string = decodeURIComponentSafe(this.props.match.params.term || "");
@observable
params: URLSearchParams = new URLSearchParams(this.props.location.search);
@observable
offset = 0;
@observable
allowLoadMore = true;
@observable
isLoading = false;
componentDidMount() {
this.handleTermChange();
if (this.props.location.search) {
this.handleQueryChange();
}
}
componentDidUpdate(prevProps: Props) {
if (prevProps.location.search !== this.props.location.search) {
this.handleQueryChange();
}
if (prevProps.match.params.term !== this.props.match.params.term) {
this.handleTermChange();
}
}
goBack = () => {
this.props.history.goBack();
// 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;
userId?: string | undefined;
dateFilter?: TDateFilter;
includeArchived?: boolean | undefined;
titleFilter?: boolean | undefined;
}) => {
history.replace({
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), ...search },
{
skipEmptyString: true,
}
),
});
};
handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
this.updateLocation(ev.currentTarget.value);
void this.fetchResults();
updateLocation(ev.currentTarget.value);
return;
}
if (ev.key === "Escape") {
ev.preventDefault();
return this.goBack();
return history.goBack();
}
if (ev.key === "ArrowUp") {
@@ -130,312 +157,127 @@ class Search extends React.Component<Props> {
}
}
const firstItem = this.firstResultItem ?? this.firstRecentSearchItem;
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();
}
};
handleQueryChange = () => {
this.params = new URLSearchParams(this.props.location.search);
this.offset = 0;
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isLoading = true;
void this.fetchResults();
};
const handleEscape = () => searchInputRef.current?.focus();
const showEmpty = !loading && query && data?.length === 0;
handleTermChange = () => {
const query = decodeURIComponentSafe(this.props.match.params.term || "");
this.query = query ? query : "";
this.offset = 0;
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isLoading = true;
void this.fetchResults();
};
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={`${t("Search")}`}
onKeyDown={handleKeyDown}
defaultValue={query}
/>
handleFilterChange = (search: {
collectionId?: string | undefined;
userId?: string | undefined;
dateFilter?: TDateFilter;
includeArchived?: boolean | undefined;
titleFilter?: boolean | undefined;
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify(
{ ...queryString.parse(this.props.location.search), ...search },
{
skipEmptyString: true,
}
),
});
};
handleTitleFilterChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.handleFilterChange({ titleFilter: ev.target.checked });
};
get firstResultItem() {
const linkItems = this.resultListCompositeRef?.querySelectorAll(
"[href]"
) as NodeListOf<HTMLAnchorElement>;
return linkItems?.[0];
}
get firstRecentSearchItem() {
const linkItems = this.recentSearchesCompositeRef?.querySelectorAll(
"li > [href]"
) as NodeListOf<HTMLAnchorElement>;
return linkItems?.[0];
}
get includeArchived() {
return this.params.get("includeArchived") === "true";
}
get collectionId() {
const id = this.params.get("collectionId");
return id ? id : undefined;
}
get userId() {
const id = this.params.get("userId");
return id ? id : undefined;
}
get dateFilter() {
const id = this.params.get("dateFilter");
return id ? (id as TDateFilter) : undefined;
}
get titleFilter() {
return this.params.get("titleFilter") === "true";
}
get isFiltered() {
return (
this.dateFilter ||
this.userId ||
this.collectionId ||
this.includeArchived ||
this.titleFilter
);
}
get title() {
const query = this.query;
const title = this.props.t("Search");
if (query) {
return `${query} ${title}`;
}
return title;
}
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isLoading) {
return;
}
// Fetch more results
await this.fetchResults();
};
@action
fetchResults = async () => {
if (this.query.trim()) {
const params = {
offset: this.offset,
limit: Pagination.defaultLimit,
dateFilter: this.dateFilter,
includeArchived: this.includeArchived,
includeDrafts: true,
collectionId: this.collectionId,
userId: this.userId,
titleFilter: this.titleFilter,
};
// we just requested this thing no need to try again
if (this.lastQuery === this.query && isEqual(params, this.lastParams)) {
this.isLoading = false;
return;
}
this.isLoading = true;
this.lastQuery = this.query;
this.lastParams = params;
try {
const results = this.titleFilter
? await this.props.documents.searchTitles(this.query, params)
: await this.props.documents.search(this.query, params);
// Add to the searches store so this search can immediately appear in
// the recent searches list without a flash of load
this.props.searches.add({
id: uuidv4(),
query: this.query,
createdAt: new Date().toISOString(),
});
if (results.length === 0 || results.length < Pagination.defaultLimit) {
this.allowLoadMore = false;
} else {
this.offset += Pagination.defaultLimit;
}
} catch (error) {
Logger.error("Search query failed", error);
this.lastQuery = "";
} finally {
this.isLoading = false;
}
} else {
this.isLoading = false;
this.lastQuery = this.query;
}
};
updateLocation = (query: string) => {
this.props.history.replace({
pathname: searchPath(query),
search: this.props.location.search,
});
};
setResultListCompositeRef = (ref: HTMLDivElement | null) => {
this.resultListCompositeRef = ref;
};
setRecentSearchesCompositeRef = (ref: HTMLDivElement | null) => {
this.recentSearchesCompositeRef = ref;
};
setSearchInputRef = (ref: HTMLInputElement | null) => {
this.searchInputRef = ref;
};
handleEscape = () => {
this.searchInputRef?.focus();
};
render() {
const { documents, notFound, t } = this.props;
const results = documents.searchResults(this.query);
const showEmpty = !this.isLoading && this.query && results?.length === 0;
return (
<Scene textTitle={this.title}>
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
{this.isLoading && <LoadingIndicator />}
{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
ref={this.setSearchInputRef}
placeholder={`${t("Search")}`}
onKeyDown={this.handleKeyDown}
defaultValue={this.query}
/>
{this.query ? (
{query ? (
<>
<Filters>
<StatusFilter
includeArchived={this.includeArchived}
includeArchived={includeArchived}
onSelect={(includeArchived) =>
this.handleFilterChange({
includeArchived,
})
handleFilterChange({ includeArchived })
}
/>
<CollectionFilter
collectionId={this.collectionId}
collectionId={collectionId}
onSelect={(collectionId) =>
this.handleFilterChange({
collectionId,
})
handleFilterChange({ collectionId })
}
/>
<UserFilter
userId={this.userId}
onSelect={(userId) =>
this.handleFilterChange({
userId,
})
}
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
<DateFilter
dateFilter={this.dateFilter}
onSelect={(dateFilter) =>
this.handleFilterChange({
dateFilter,
})
}
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={this.handleTitleFilterChange}
checked={this.titleFilter}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
handleFilterChange({ titleFilter: ev.target.checked });
}}
checked={titleFilter}
/>
</Filters>
) : (
<RecentSearches
ref={this.setRecentSearchesCompositeRef}
onEscape={this.handleEscape}
/>
)}
{showEmpty && (
<Fade>
<Centered column>
<Text type="secondary">
<Trans>No documents found for your search filters.</Trans>
</Text>
</Centered>
</Fade>
)}
<ResultList column>
<StyledArrowKeyNavigation
ref={this.setResultListCompositeRef}
onEscape={this.handleEscape}
aria-label={t("Search Results")}
>
{(compositeProps) =>
results?.map((result) => {
const document = documents.data.get(result.document.id);
if (!document) {
return null;
}
return (
<DocumentListItem
key={document.id}
document={document}
highlight={this.query}
context={result.context}
showCollection
showTemplate
{...compositeProps}
/>
);
})
}
</StyledArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
{showEmpty && (
<Fade>
<Centered column>
<Text type="secondary">
{t("No documents found for your search filters.")}
</Text>
</Centered>
</Fade>
)}
</ResultList>
</ResultsWrapper>
</Scene>
);
}
<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>
</>
) : (
<RecentSearches
ref={recentSearchesCompositeRef}
onEscape={handleEscape}
/>
)}
</ResultsWrapper>
</Scene>
);
}
const Centered = styled(Flex)`
@@ -487,4 +329,4 @@ const SearchTitlesFilter = styled(Switch)`
font-weight: 400;
`;
export default withTranslation()(withStores(withRouter(Search)));
export default observer(Search);

View File

@@ -43,9 +43,6 @@ export default class DocumentsStore extends Store<Document> {
{ sharedTree: NavigationNode; team: PublicTeam } | undefined
> = new Map();
@observable
searchCache: Map<string, SearchResult[] | undefined> = new Map();
@observable
backlinks: Map<string, string[]> = new Map();
@@ -173,10 +170,6 @@ export default class DocumentsStore extends Store<Document> {
return naturalSort(this.inCollection(collectionId), "title");
}
searchResults(query: string): SearchResult[] | undefined {
return this.searchCache.get(query);
}
@computed
get archived(): Document[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
@@ -367,7 +360,10 @@ export default class DocumentsStore extends Store<Document> {
this.fetchNamedPage("list", options);
@action
searchTitles = async (query: string, options?: SearchParams) => {
searchTitles = async (
query: string,
options?: SearchParams
): Promise<SearchResult[]> => {
const compactedOptions = omitBy(options, (o) => !o);
const res = await client.post("/documents.search_titles", {
...compactedOptions,
@@ -388,15 +384,12 @@ export default class DocumentsStore extends Store<Document> {
return null;
}
return {
id: document.id,
document,
};
})
);
const existing = this.searchCache.get(query) || [];
// splice modifies any existing results, taking into account pagination
existing.splice(0, existing.length, ...results);
this.searchCache.set(query, existing);
return res.data;
return results;
};
@action
@@ -431,11 +424,7 @@ export default class DocumentsStore extends Store<Document> {
};
})
);
const existing = this.searchCache.get(query) || [];
// splice modifies any existing results, taking into account pagination
existing.splice(options.offset || 0, options.limit || 0, ...results);
this.searchCache.set(query, existing);
return res.data;
return results;
};
@action