diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 85f322aa6..234b8592e 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -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 | null) { const results = await documents.searchTitles(term); return sortBy( - results.map((document: Document) => ({ + results.map(({ document }) => ({ title: document.title, subtitle: , url: document.url, diff --git a/app/components/SearchListItem.tsx b/app/components/SearchListItem.tsx index 23483ffde..e9c5a98eb 100644 --- a/app/components/SearchListItem.tsx +++ b/app/components/SearchListItem.tsx @@ -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} { diff --git a/app/components/SearchPopover.tsx b/app/components/SearchPopover.tsx index ad696a9a0..961ed184f 100644 --- a/app/components/SearchPopover.tsx +++ b/app/components/SearchPopover.tsx @@ -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; }, diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx index 43a75a327..fd83ac884 100644 --- a/app/editor/components/LinkEditor.tsx +++ b/app/editor/components/LinkEditor.tsx @@ -25,7 +25,7 @@ import Tooltip from "./Tooltip"; export type SearchResult = { title: string; - subtitle?: string; + subtitle?: React.ReactNode; url: string; }; diff --git a/app/hooks/usePaginatedRequest.ts b/app/hooks/usePaginatedRequest.ts index c2e930d98..ec9e7023b 100644 --- a/app/hooks/usePaginatedRequest.ts +++ b/app/hooks/usePaginatedRequest.ts @@ -14,6 +14,8 @@ type RequestResponse = { next: () => void; /** Page number */ page: number; + /** Offset */ + offset: number; /** Marks the end of pagination */ end: boolean; }; @@ -64,9 +66,7 @@ export default function usePaginatedRequest( 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( 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 }; } diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index c1e8b11ac..cf0b05415 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -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(null); + const resultListCompositeRef = React.useRef(null); + const recentSearchesCompositeRef = React.useRef(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 { - 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) => { + const handleKeyDown = (ev: React.KeyboardEvent) => { 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 { } } - const firstItem = this.firstResultItem ?? this.firstRecentSearchItem; + const firstResultItem = ( + resultListCompositeRef.current?.querySelectorAll( + "[href]" + ) as NodeListOf + )?.[0]; + + const firstRecentSearchItem = ( + recentSearchesCompositeRef.current?.querySelectorAll( + "li > [href]" + ) as NodeListOf + )?.[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 ( + + + {loading && } + {props.notFound && ( +
+

{t("Not Found")}

+ + {t("We were unable to find the page you’re looking for.")} + +
+ )} + + - 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) => { - this.handleFilterChange({ titleFilter: ev.target.checked }); - }; - - get firstResultItem() { - const linkItems = this.resultListCompositeRef?.querySelectorAll( - "[href]" - ) as NodeListOf; - return linkItems?.[0]; - } - - get firstRecentSearchItem() { - const linkItems = this.recentSearchesCompositeRef?.querySelectorAll( - "li > [href]" - ) as NodeListOf; - 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 we’re 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 ( - - - {this.isLoading && } - {notFound && ( -
-

{t("Not Found")}

- - {t("We were unable to find the page you’re looking for.")} - -
- )} - - - - {this.query ? ( + {query ? ( + <> - this.handleFilterChange({ - includeArchived, - }) + handleFilterChange({ includeArchived }) } /> - this.handleFilterChange({ - collectionId, - }) + handleFilterChange({ collectionId }) } /> - this.handleFilterChange({ - userId, - }) - } + userId={userId} + onSelect={(userId) => handleFilterChange({ userId })} /> - this.handleFilterChange({ - dateFilter, - }) - } + dateFilter={dateFilter} + onSelect={(dateFilter) => handleFilterChange({ dateFilter })} /> ) => { + handleFilterChange({ titleFilter: ev.target.checked }); + }} + checked={titleFilter} /> - ) : ( - - )} - {showEmpty && ( - - - - No documents found for your search filters. - - - - )} - - - {(compositeProps) => - results?.map((result) => { - const document = documents.data.get(result.document.id); - if (!document) { - return null; - } - return ( - - ); - }) - } - - {this.allowLoadMore && ( - + {showEmpty && ( + + + + {t("No documents found for your search filters.")} + + + )} - - -
- ); - } + + + {(compositeProps) => + data?.length + ? data.map((result) => ( + + )) + : null + } + + + + + ) : ( + + )} +
+
+ ); } 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); diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 6921dbd49..49e1d0bcf 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -43,9 +43,6 @@ export default class DocumentsStore extends Store { { sharedTree: NavigationNode; team: PublicTeam } | undefined > = new Map(); - @observable - searchCache: Map = new Map(); - @observable backlinks: Map = new Map(); @@ -173,10 +170,6 @@ export default class DocumentsStore extends Store { 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 { this.fetchNamedPage("list", options); @action - searchTitles = async (query: string, options?: SearchParams) => { + searchTitles = async ( + query: string, + options?: SearchParams + ): Promise => { 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 { 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 { }; }) ); - 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