Revert "feat: Port scenes/Search to functional component style (#3800)" (#3827)

This reverts commit 5758ff3459.
This commit is contained in:
Tom Moor
2022-07-21 11:37:27 +01:00
committed by GitHub
parent 80d50e3d88
commit 4b4b0f7037
3 changed files with 443 additions and 411 deletions

View File

@@ -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<Props> {
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<HTMLInputElement>) => {
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<HTMLAnchorElement>;
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 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: 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 (
<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 ? (
<Filters>
<StatusFilter
includeArchived={this.includeArchived}
onSelect={(includeArchived) =>
this.handleFilterChange({
includeArchived,
})
}
/>
<CollectionFilter
collectionId={this.collectionId}
onSelect={(collectionId) =>
this.handleFilterChange({
collectionId,
})
}
/>
<UserFilter
userId={this.userId}
onSelect={(userId) =>
this.handleFilterChange({
userId,
})
}
/>
<DateFilter
dateFilter={this.dateFilter}
onSelect={(dateFilter) =>
this.handleFilterChange({
dateFilter,
})
}
/>
</Filters>
) : (
<RecentSearches />
)}
{showEmpty && (
<Fade>
<Centered column>
<Text type="secondary">
<Trans>No documents found for your search filters.</Trans>
</Text>
</Centered>
</Fade>
)}
<ResultList column>
<StyledArrowKeyNavigation
ref={this.setCompositeRef}
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} />
)}
</ResultList>
</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;
${breakpoint("tablet")`
padding: 0;
`};
&:hover {
opacity: 1;
}
`;
export default withTranslation()(withStores(withRouter(Search)));

View File

@@ -0,0 +1,3 @@
import Search from "./Search";
export default Search;

View File

@@ -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<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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<HTMLAnchorElement>;
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 were 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 (
<Scene textTitle={title}>
<RegisterKeyDown trigger="Escape" handler={goBack} />
{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={searchInputRef}
placeholder={`${t("Search")}`}
onKeyDown={handleKeyDown}
defaultValue={query}
/>
{query ? (
<Filters>
<StatusFilter
includeArchived={includeArchived}
onSelect={(includeArchived) =>
handleFilterChange({
includeArchived,
})
}
/>
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({
collectionId,
})
}
/>
<UserFilter
userId={userId}
onSelect={(userId) =>
handleFilterChange({
userId,
})
}
/>
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) =>
handleFilterChange({
dateFilter,
})
}
/>
</Filters>
) : (
<RecentSearches />
)}
{showEmpty && (
<Fade>
<Centered column>
<Text type="secondary">
<Trans>No documents found for your search filters.</Trans>
</Text>
</Centered>
</Fade>
)}
<ResultList column>
<StyledArrowKeyNavigation
ref={compositeRef}
onEscape={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={query}
context={result.context}
showCollection
showTemplate
{...compositeProps}
/>
);
})
}
</StyledArrowKeyNavigation>
{allowLoadMore && <Waypoint key={offset} onEnter={loadMoreResults} />}
</ResultList>
</ResultsWrapper>
</Scene>
);
}
// 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);