From 5758ff34595f31deced4e9431098ae1f39162e21 Mon Sep 17 00:00:00 2001 From: CuriousCorrelation <58817502+CuriousCorrelation@users.noreply.github.com> Date: Thu, 21 Jul 2022 03:36:49 +0530 Subject: [PATCH] feat: Port `scenes/Search` to functional component style (#3800) * feat: Refactor Search scene to functional style * fix: Clicking on recent not updating search input * Replace translations and root objs with stores * Replace `props.location` with `useLocation` * deconstruct `useLocation` for readability * Replace match prop term with `useParams` * [WIP] Replace props history with `useHistory` * Replace `ReactComponentProps` with state style * Remove `lastParam` check, use dependency array instead * Add explict match on param change This reverts commit bfcc4038ff13ed69e0b87e1ac898e2147f2ca6bf. --- app/scenes/Search/Search.tsx | 440 ----------------------------------- app/scenes/Search/index.ts | 3 - app/scenes/Search/index.tsx | 411 ++++++++++++++++++++++++++++++++ 3 files changed, 411 insertions(+), 443 deletions(-) delete mode 100644 app/scenes/Search/Search.tsx delete mode 100644 app/scenes/Search/index.ts create mode 100644 app/scenes/Search/index.tsx diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx deleted file mode 100644 index 1fd205c74..000000000 --- a/app/scenes/Search/Search.tsx +++ /dev/null @@ -1,440 +0,0 @@ -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 deleted file mode 100644 index c00d9e1f6..000000000 --- a/app/scenes/Search/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Search from "./Search"; - -export default Search; diff --git a/app/scenes/Search/index.tsx b/app/scenes/Search/index.tsx new file mode 100644 index 000000000..69531bcb3 --- /dev/null +++ b/app/scenes/Search/index.tsx @@ -0,0 +1,411 @@ +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);