diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx new file mode 100644 index 000000000..1fd205c74 --- /dev/null +++ b/app/scenes/Search/Search.tsx @@ -0,0 +1,440 @@ +import { isEqual } from "lodash"; +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 { Waypoint } from "react-waypoint"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import { v4 as uuidv4 } from "uuid"; +import { DateFilter as TDateFilter } from "@shared/types"; +import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore"; +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"; +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 Text from "~/components/Text"; +import withStores from "~/components/withStores"; +import Logger from "~/utils/Logger"; +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"; +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; + }; + +@observer +class Search extends React.Component { + compositeRef: 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(); + }; + + handleKeyDown = (ev: React.KeyboardEvent) => { + if (ev.key === "Enter") { + this.updateLocation(ev.currentTarget.value); + this.fetchResults(); + return; + } + + if (ev.key === "Escape") { + ev.preventDefault(); + return this.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; + } + } + + if (this.compositeRef) { + const linkItems = this.compositeRef.querySelectorAll( + "[href]" + ) as NodeListOf; + linkItems[0]?.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; + this.fetchResults(); + }; + + 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; + this.fetchResults(); + }; + + handleFilterChange = (search: { + collectionId?: string | undefined; + userId?: string | undefined; + dateFilter?: TDateFilter; + includeArchived?: boolean | undefined; + }) => { + this.props.history.replace({ + pathname: this.props.location.pathname, + search: queryString.stringify( + { ...queryString.parse(this.props.location.search), ...search }, + { + skipEmptyString: true, + } + ), + }); + }; + + 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 isFiltered() { + return ( + this.dateFilter || + this.userId || + this.collectionId || + this.includeArchived + ); + } + + 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: DEFAULT_PAGINATION_LIMIT, + dateFilter: this.dateFilter, + includeArchived: this.includeArchived, + includeDrafts: true, + collectionId: this.collectionId, + userId: this.userId, + }; + + // 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 = 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 < DEFAULT_PAGINATION_LIMIT) { + this.allowLoadMore = false; + } else { + this.offset += DEFAULT_PAGINATION_LIMIT; + } + } 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, + }); + }; + + setCompositeRef = (ref: HTMLDivElement | null) => { + this.compositeRef = 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 ? ( + + + this.handleFilterChange({ + includeArchived, + }) + } + /> + + this.handleFilterChange({ + collectionId, + }) + } + /> + + this.handleFilterChange({ + userId, + }) + } + /> + + this.handleFilterChange({ + dateFilter, + }) + } + /> + + ) : ( + + )} + {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 && ( + + )} + + +
+ ); + } +} + +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; + + ${breakpoint("tablet")` + padding: 0; + `}; + + &:hover { + opacity: 1; + } +`; + +export default withTranslation()(withStores(withRouter(Search))); diff --git a/app/scenes/Search/index.ts b/app/scenes/Search/index.ts new file mode 100644 index 000000000..c00d9e1f6 --- /dev/null +++ b/app/scenes/Search/index.ts @@ -0,0 +1,3 @@ +import Search from "./Search"; + +export default Search; diff --git a/app/scenes/Search/index.tsx b/app/scenes/Search/index.tsx deleted file mode 100644 index 69531bcb3..000000000 --- a/app/scenes/Search/index.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import { isEqual } from "lodash"; -import { observer } from "mobx-react"; -import queryString from "query-string"; -import * as React from "react"; -import { Trans, useTranslation } from "react-i18next"; -import { useHistory, useLocation, useParams } from "react-router"; -import { Waypoint } from "react-waypoint"; -import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import { v4 as uuidV4 } from "uuid"; -import { DateFilter as TDateFilter } from "@shared/types"; -import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore"; -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 Text from "~/components/Text"; -import useStores from "~/hooks/useStores"; -import Logger from "~/utils/Logger"; -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"; -import SearchInput from "./components/SearchInput"; -import StatusFilter from "./components/StatusFilter"; -import UserFilter from "./components/UserFilter"; - -type Props = { - notFound?: boolean; -}; - -function Search(props: Props) { - const { t } = useTranslation(); - const { documents, searches } = useStores(); - const { pathname, search } = useLocation(); - const pathParam = useParams<{ term?: string }>(); - const history = useHistory(); - - const term = decodeURIComponentSafe(pathParam.term || ""); - - const [query, setQuery] = React.useState(term); - - const title = query ? `${query} – ${t("Search")}` : t("Search"); - - const compositeRef = React.useRef(null); - const searchInputRef = React.useRef(null); - - const [params, setParams] = React.useState(new URLSearchParams(search)); - - const includeArchived = mightExist(params.get("includeArchived")) === "true"; - const dateFilter = mightExist(params.get("dateFilter")) as TDateFilter; - const userId = mightExist(params.get("userId")); - const collectionId = mightExist(params.get("collectionId")); - - // Case where user has called search again - // without changing search term. - const [cachedQuery, setCachedQuery] = React.useState(""); - const [offset, setOffset] = React.useState(0); - const [lastParams, setLastParams] = React.useState({}); - const [isLoading, setIsLoading] = React.useState(false); - const [allowLoadMore, setAllowLoadMore] = React.useState(true); - - const fetchResults = React.useCallback(async () => { - if (query.trim()) { - const params = { - offset: offset, - limit: DEFAULT_PAGINATION_LIMIT, - dateFilter: dateFilter, - includeArchived: includeArchived, - includeDrafts: true, - collectionId: collectionId, - userId: userId, - }; - - // we just requested this thing – no need to try again - if (cachedQuery === query && isEqual(params, lastParams)) { - setIsLoading(false); - return; - } - - setIsLoading(true); - setCachedQuery(query); - setLastParams(params); - - try { - const results = await documents.search(query, params); - - // Add to the searches store so this search can immediately appear in - // the recent searches list without a flash of load - searches.add({ - id: uuidV4(), - query: query, - createdAt: new Date().toISOString(), - }); - - if (results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT) { - setAllowLoadMore(false); - } else { - setOffset(DEFAULT_PAGINATION_LIMIT); - } - } catch (error) { - Logger.error("Search query failed", error); - setCachedQuery(""); - } finally { - setIsLoading(false); - } - } else { - setIsLoading(false); - setCachedQuery(query); - } - }, [ - collectionId, - dateFilter, - includeArchived, - lastParams, - cachedQuery, - offset, - documents, - searches, - query, - userId, - ]); - - const updateLocation = React.useCallback( - (query: string) => { - history.replace({ - pathname: searchPath(query), - search: search, - }); - }, - [history, search] - ); - - const handleQueryChange = React.useCallback(() => { - setParams(new URLSearchParams(search)); - setOffset(0); - setAllowLoadMore(true); - // To prevent "no results" showing before debounce kicks in - setIsLoading(true); - fetchResults(); - }, [fetchResults, search]); - - const handleTermChange = React.useCallback(() => { - const potentialQuery = decodeURIComponentSafe(term); - setQuery(potentialQuery ? potentialQuery : ""); - setOffset(0); - setAllowLoadMore(true); - // To prevent "no results" showing before debounce kicks in - setIsLoading(true); - fetchResults(); - }, [fetchResults, term]); - - React.useEffect(() => { - handleQueryChange(); - }, [handleQueryChange, search]); - - React.useEffect(() => { - handleTermChange(); - }, [handleTermChange, term]); - - const goBack = React.useCallback(() => { - history.goBack(); - }, [history]); - - const handleKeyDown = React.useCallback( - (ev: React.KeyboardEvent) => { - if (ev.key === "Enter") { - updateLocation(ev.currentTarget.value); - fetchResults(); - return; - } - - if (ev.key === "Escape") { - ev.preventDefault(); - return 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; - } - } - - if (compositeRef) { - const linkItems = compositeRef.current?.querySelectorAll( - "[href]" - ) as NodeListOf; - linkItems[0]?.focus(); - } - } - }, - [fetchResults, goBack, updateLocation] - ); - - const handleFilterChange = React.useCallback( - (updateSearch: { - collectionId?: string | undefined; - userId?: string | undefined; - dateFilter?: TDateFilter; - includeArchived?: boolean | undefined; - }) => { - history.replace({ - pathname: pathname, - search: queryString.stringify( - { ...queryString.parse(search), ...updateSearch }, - { - skipEmptyString: true, - } - ), - }); - }, - [history, pathname, search] - ); - - const loadMoreResults = React.useCallback(async () => { - // Don't paginate if there aren't more results or we’re in the middle of fetching - if (!allowLoadMore || isLoading) { - return; - } - - // Fetch more results - await fetchResults(); - }, [allowLoadMore, isLoading, fetchResults]); - - const handleEscape = React.useCallback(() => { - searchInputRef?.current?.focus(); - }, [searchInputRef]); - - const { notFound } = props; - const results = documents.searchResults(query); - const showEmpty = !isLoading && query && results?.length === 0; - - // Set `InputSearch` box value when - // `term` changes. - React.useEffect(() => { - if (searchInputRef.current) { - searchInputRef.current.value = term; - } - }, [term]); - - return ( - - - {isLoading && } - {notFound && ( -
-

{t("Not Found")}

- - {t("We were unable to find the page you’re looking for.")} - -
- )} - - - - {query ? ( - - - handleFilterChange({ - includeArchived, - }) - } - /> - - handleFilterChange({ - collectionId, - }) - } - /> - - handleFilterChange({ - userId, - }) - } - /> - - handleFilterChange({ - dateFilter, - }) - } - /> - - ) : ( - - )} - {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 ( - - ); - }) - } - - {allowLoadMore && } - - -
- ); -} - -// Helper function to collapse `null | undefined` -// to just `undefined` -const mightExist = (value: string | null | undefined): string | undefined => { - return value ? value : undefined; -}; - -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; - - ${breakpoint("tablet")` - padding: 0; - `}; - - &:hover { - opacity: 1; - } -`; - -export default observer(Search);