This repository has been archived on 2022-08-14. You can view files and clone it, but cannot push or open issues or pull requests.
outline/app/scenes/Search/Search.js

392 lines
11 KiB
JavaScript
Raw Normal View History

2017-05-12 00:23:56 +00:00
// @flow
import * as React from "react";
import ReactDOM from "react-dom";
import keydown from "react-keydown";
import { Waypoint } from "react-waypoint";
import { withRouter, Link } from "react-router-dom";
2020-08-08 22:58:24 +00:00
import type { Location, RouterHistory, Match } from "react-router-dom";
import { PlusIcon } from "outline-icons";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import { debounce } from "lodash";
import queryString from "query-string";
import styled from "styled-components";
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import UsersStore from "stores/UsersStore";
import { newDocumentUrl, searchUrl } from "utils/routeHelpers";
import { meta } from "utils/keyboard";
import Flex from "components/Flex";
import Button from "components/Button";
import Empty from "components/Empty";
import Fade from "components/Fade";
import HelpText from "components/HelpText";
import CenteredContent from "components/CenteredContent";
import LoadingIndicator from "components/LoadingIndicator";
import DocumentPreview from "components/DocumentPreview";
import NewDocumentMenu from "menus/NewDocumentMenu";
import PageTitle from "components/PageTitle";
import SearchField from "./components/SearchField";
import StatusFilter from "./components/StatusFilter";
import CollectionFilter from "./components/CollectionFilter";
import UserFilter from "./components/UserFilter";
import DateFilter from "./components/DateFilter";
2017-05-10 07:02:11 +00:00
2017-05-12 00:23:56 +00:00
type Props = {
history: RouterHistory,
2020-08-08 22:58:24 +00:00
match: Match,
location: Location,
2017-09-04 21:48:56 +00:00
documents: DocumentsStore,
users: UsersStore,
2017-05-17 07:11:13 +00:00
notFound: ?boolean,
2017-05-12 00:23:56 +00:00
};
2017-11-10 22:14:30 +00:00
@observer
2018-05-05 23:16:08 +00:00
class Search extends React.Component<Props> {
2018-11-07 05:58:32 +00:00
firstDocument: ?DocumentPreview;
2016-07-19 07:30:47 +00:00
@observable
query: string = decodeURIComponent(this.props.match.params.term || "");
@observable params: URLSearchParams = new URLSearchParams();
2017-12-04 00:50:50 +00:00
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
2018-02-05 01:31:12 +00:00
@observable isFetching: boolean = false;
@observable pinToTop: boolean = !!this.props.match.params.term;
2017-09-04 21:48:56 +00:00
componentDidMount() {
this.handleTermChange();
if (this.props.location.search) {
this.handleQueryChange();
}
2016-07-19 07:30:47 +00:00
}
componentDidUpdate(prevProps) {
if (prevProps.location.search !== this.props.location.search) {
2017-11-12 18:55:13 +00:00
this.handleQueryChange();
}
if (prevProps.match.params.term !== this.props.match.params.term) {
this.handleTermChange();
}
}
@keydown("esc")
goBack() {
this.props.history.goBack();
}
handleKeyDown = ev => {
// Escape
if (ev.which === 27) {
ev.preventDefault();
this.goBack();
}
// Down
if (ev.which === 40) {
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.findDOMNode(this.firstDocument);
2017-11-12 18:55:13 +00:00
if (element instanceof HTMLElement) element.focus();
}
}
};
2017-11-12 18:55:13 +00:00
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.isFetching = true;
this.fetchResultsDebounced();
};
handleTermChange = () => {
const query = decodeURIComponent(this.props.match.params.term || "");
this.query = query ? query : "";
2018-02-05 01:27:57 +00:00
this.offset = 0;
2017-12-04 00:50:50 +00:00
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isFetching = !!this.query;
2017-12-04 00:50:50 +00:00
2017-11-12 18:55:13 +00:00
this.fetchResultsDebounced();
};
handleFilterChange = search => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
});
};
handleNewDoc = () => {
if (this.collectionId) {
this.props.history.push(newDocumentUrl(this.collectionId));
}
};
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 : undefined;
}
get isFiltered() {
return (
this.dateFilter ||
this.userId ||
this.collectionId ||
this.includeArchived
);
}
get title() {
const query = this.query;
const title = "Search";
if (query) return `${query} ${title}`;
return title;
}
2017-12-04 00:50:50 +00:00
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
2017-12-04 00:50:50 +00:00
if (!this.allowLoadMore || this.isFetching) return;
// Fetch more results
await this.fetchResults();
};
2017-11-10 22:14:30 +00:00
@action
2017-11-12 18:55:13 +00:00
fetchResults = async () => {
if (this.query) {
this.isFetching = true;
2017-09-04 21:48:56 +00:00
try {
const results = await this.props.documents.search(this.query, {
2017-12-04 00:50:50 +00:00
offset: this.offset,
limit: DEFAULT_PAGINATION_LIMIT,
dateFilter: this.dateFilter,
includeArchived: this.includeArchived,
includeDrafts: true,
collectionId: this.collectionId,
userId: this.userId,
2017-12-04 00:50:50 +00:00
});
this.pinToTop = true;
if (results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT) {
2017-12-04 00:50:50 +00:00
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
} finally {
this.isFetching = false;
2017-09-04 21:48:56 +00:00
}
} else {
2018-02-05 01:27:57 +00:00
this.pinToTop = false;
2017-09-04 21:48:56 +00:00
}
};
fetchResultsDebounced = debounce(this.fetchResults, 350, {
leading: false,
trailing: true,
});
2017-11-12 18:55:13 +00:00
updateLocation = query => {
this.props.history.replace({
pathname: searchUrl(query),
search: this.props.location.search,
});
};
setFirstDocumentRef = ref => {
this.firstDocument = ref;
};
2016-07-18 03:59:32 +00:00
render() {
2019-03-10 00:10:53 +00:00
const { documents, notFound, location } = this.props;
const results = documents.searchResults(this.query);
const showEmpty = !this.isFetching && this.query && results.length === 0;
2019-03-10 00:10:53 +00:00
const showShortcutTip =
!this.pinToTop && location.state && location.state.fromMenu;
2016-07-19 07:30:47 +00:00
2016-07-18 03:59:32 +00:00
return (
<Container auto>
<PageTitle title={this.title} />
2017-09-04 21:48:56 +00:00
{this.isFetching && <LoadingIndicator />}
2017-11-10 22:14:30 +00:00
{notFound && (
<div>
<h1>Not Found</h1>
2018-06-08 04:35:40 +00:00
<Empty>We were unable to find the page youre looking for.</Empty>
2017-11-10 22:14:30 +00:00
</div>
)}
2018-02-05 01:27:57 +00:00
<ResultsWrapper pinToTop={this.pinToTop} column auto>
<SearchField
onKeyDown={this.handleKeyDown}
2017-11-12 18:55:13 +00:00
onChange={this.updateLocation}
defaultValue={this.query}
/>
2019-03-10 00:10:53 +00:00
{showShortcutTip && (
<Fade>
<HelpText small>
Use the <strong>{meta}+K</strong> shortcut to search from
anywhere in Outline
2019-03-10 00:10:53 +00:00
</HelpText>
</Fade>
)}
{this.pinToTop && (
<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>
)}
{showEmpty && (
<Fade>
<Empty>
<Centered column>
<HelpText>
No documents found for your search filters. <br />Create a
new document?
</HelpText>
<Wrapper>
{this.collectionId ? (
<Button
onClick={this.handleNewDoc}
icon={<PlusIcon />}
primary
>
New doc
</Button>
) : (
<NewDocumentMenu />
)}&nbsp;&nbsp;
<Button as={Link} to="/search" neutral>
Clear filters
</Button>
</Wrapper>
</Centered>
</Empty>
</Fade>
)}
2018-02-05 01:27:57 +00:00
<ResultList column visible={this.pinToTop}>
2017-07-12 08:05:28 +00:00
<StyledArrowKeyNavigation
2017-07-02 00:16:18 +00:00
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{results.map((result, index) => {
const document = documents.data.get(result.document.id);
if (!document) return null;
return (
<DocumentPreview
2018-11-07 05:58:32 +00:00
ref={ref => index === 0 && this.setFirstDocumentRef(ref)}
key={document.id}
document={document}
2017-11-12 18:55:13 +00:00
highlight={this.query}
context={result.context}
showCollection
/>
);
2017-09-04 21:48:56 +00:00
})}
2017-07-12 08:05:28 +00:00
</StyledArrowKeyNavigation>
2017-12-04 00:50:50 +00:00
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
2017-07-02 00:16:18 +00:00
</ResultList>
</ResultsWrapper>
</Container>
2016-07-18 03:59:32 +00:00
);
}
}
const Wrapper = styled(Flex)`
justify-content: center;
margin: 10px 0;
`;
const Centered = styled(Flex)`
text-align: center;
margin: 30vh auto 0;
max-width: 380px;
transform: translateY(-50%);
`;
const Container = styled(CenteredContent)`
> div {
position: relative;
height: 100%;
}
`;
const ResultsWrapper = styled(Flex)`
position: absolute;
transition: all 300ms cubic-bezier(0.65, 0.05, 0.36, 1);
top: ${props => (props.pinToTop ? "0%" : "50%")};
margin-top: ${props => (props.pinToTop ? "40px" : "-75px")};
width: 100%;
`;
const ResultList = styled(Flex)`
margin-bottom: 150px;
opacity: ${props => (props.visible ? "1" : "0")};
transition: all 400ms cubic-bezier(0.65, 0.05, 0.36, 1);
`;
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;
&:hover {
opacity: 1;
}
`;
export default withRouter(inject("documents")(Search));