feat: Show recent searches (#2868)

* stash

* root hookup

* recent searches UI

* feat: Add search query deletion

* simplify no results state

* lint
This commit is contained in:
Tom Moor
2021-12-19 11:08:28 -08:00
committed by GitHub
parent 81f3347ecf
commit 6fc1b5cc22
15 changed files with 267 additions and 100 deletions

28
app/models/SearchQuery.ts Normal file
View File

@@ -0,0 +1,28 @@
import { client } from "~/utils/ApiClient";
import BaseModel from "./BaseModel";
class SearchQuery extends BaseModel {
id: string;
query: string;
delete = async () => {
this.isSaving = true;
try {
await client.post(`/searches.delete`, {
query: this.query,
});
this.store.data.forEach((searchQuery: SearchQuery) => {
if (searchQuery.query === this.query) {
this.store.remove(searchQuery.id);
}
});
} finally {
this.isSaving = false;
}
};
}
export default SearchQuery;

View File

@@ -2,19 +2,17 @@ import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
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 { Link } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
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 Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty";
@@ -25,11 +23,11 @@ import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import withStores from "~/components/withStores";
import NewDocumentMenu from "~/menus/NewDocumentMenu";
import { newDocumentPath, searchUrl } from "~/utils/routeHelpers";
import { searchUrl } 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";
@@ -46,11 +44,11 @@ type Props = RouteComponentProps<
@observer
class Search extends React.Component<Props> {
firstDocument: React.Component<any> | null | undefined;
firstDocument: HTMLAnchorElement | null | undefined;
lastQuery = "";
lastParams: Record<string, any>;
lastParams: SearchParams;
@observable
query: string = decodeURIComponentSafe(this.props.match.params.term || "");
@@ -67,9 +65,6 @@ class Search extends React.Component<Props> {
@observable
isLoading = false;
@observable
pinToTop = !!this.props.match.params.term;
componentDidMount() {
this.handleTermChange();
@@ -151,12 +146,6 @@ class Search extends React.Component<Props> {
});
};
handleNewDoc = () => {
if (this.collectionId) {
this.props.history.push(newDocumentPath(this.collectionId));
}
};
get includeArchived() {
return this.params.get("includeArchived") === "true";
}
@@ -196,6 +185,7 @@ class Search extends React.Component<Props> {
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();
};
@@ -225,7 +215,6 @@ class Search extends React.Component<Props> {
try {
const results = await this.props.documents.search(this.query, params);
this.pinToTop = true;
if (results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT) {
this.allowLoadMore = false;
@@ -239,7 +228,6 @@ class Search extends React.Component<Props> {
this.isLoading = false;
}
} else {
this.pinToTop = false;
this.isLoading = false;
this.lastQuery = this.query;
}
@@ -252,17 +240,14 @@ class Search extends React.Component<Props> {
});
};
setFirstDocumentRef = (ref: any) => {
setFirstDocumentRef = (ref: HTMLAnchorElement | null) => {
this.firstDocument = ref;
};
render() {
const { documents, notFound, location, t, auth, policies } = this.props;
const { documents, notFound, t } = this.props;
const results = documents.searchResults(this.query);
const showEmpty = !this.isLoading && this.query && results.length === 0;
const showShortcutTip = !this.pinToTop && location.state?.fromMenu;
const can = policies.abilities(auth.team?.id ? auth.team.id : "");
const canCollection = policies.abilities(this.collectionId || "");
return (
<Container>
@@ -277,28 +262,14 @@ class Search extends React.Component<Props> {
</Empty>
</div>
)}
<ResultsWrapper pinToTop={this.pinToTop} column auto>
<ResultsWrapper column auto>
<SearchInput
placeholder={`${t("Search")}`}
onKeyDown={this.handleKeyDown}
defaultValue={this.query}
/>
{showShortcutTip && (
<Fade>
<HelpText small>
<Trans
defaults="Use the <em>{{ shortcut }}</em> shortcut to search from anywhere in your knowledge base"
values={{
shortcut: "/",
}}
components={{
em: <strong />,
}}
/>
</HelpText>
</Fade>
)}
{this.pinToTop && (
{this.query ? (
<Filters>
<StatusFilter
includeArchived={this.includeArchived}
@@ -333,39 +304,19 @@ class Search extends React.Component<Props> {
}
/>
</Filters>
) : (
<RecentSearches />
)}
{showEmpty && (
<Fade>
<Centered column>
<HelpText>
<Trans>
No documents found for your search filters. <br />
</Trans>
{can.createDocument && <Trans>Create a new document?</Trans>}
<Trans>No documents found for your search filters.</Trans>
</HelpText>
<Wrapper>
{this.collectionId &&
can.createDocument &&
canCollection.update ? (
<Button
onClick={this.handleNewDoc}
icon={<PlusIcon />}
primary
>
{t("New doc")}
</Button>
) : (
<NewDocumentMenu />
)}
&nbsp;&nbsp;
<Button as={Link} to="/search" neutral>
{t("Clear filters")}
</Button>
</Wrapper>
</Centered>
</Fade>
)}
<ResultList column visible={this.pinToTop}>
<ResultList column>
<StyledArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
@@ -396,11 +347,6 @@ class Search extends React.Component<Props> {
}
}
const Wrapper = styled(Flex)`
justify-content: center;
margin: 10px 0;
`;
const Centered = styled(Flex)`
text-align: center;
margin: 30vh auto 0;
@@ -415,21 +361,14 @@ const Container = styled(CenteredContent)`
}
`;
const ResultsWrapper = styled(Flex)<{ pinToTop: boolean }>`
position: absolute;
transition: all 300ms cubic-bezier(0.65, 0.05, 0.36, 1);
top: ${(props) => (props.pinToTop ? "0%" : "50%")};
width: 100%;
const ResultsWrapper = styled(Flex)`
${breakpoint("tablet")`
margin-top: ${(props: any) => (props.pinToTop ? "40px" : "-75px")};
margin-top: 40px;
`};
`;
const ResultList = styled(Flex)<{ visible: boolean }>`
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)`

View File

@@ -0,0 +1,102 @@
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
import { searchUrl } from "~/utils/routeHelpers";
function RecentSearches() {
const { searches } = useStores();
const { t } = useTranslation();
React.useEffect(() => {
searches.fetchPage({});
}, [searches]);
return searches.recent.length ? (
<>
<Heading>{t("Recent searches")}</Heading>
<List>
{searches.recent.map((searchQuery) => (
<ListItem>
<RecentSearch
key={searchQuery.id}
to={searchUrl(searchQuery.query)}
>
{searchQuery.query}
<Tooltip tooltip={t("Remove search")} delay={150}>
<RemoveButton
onClick={(ev) => {
ev.preventDefault();
searchQuery.delete();
}}
>
<CloseIcon color="currentColor" />
</RemoveButton>
</Tooltip>
</RecentSearch>
</ListItem>
))}
</List>
</>
) : null;
}
const Heading = styled.h2`
font-weight: 500;
font-size: 14px;
line-height: 1.5;
color: ${(props) => props.theme.textSecondary};
margin-bottom: 0;
`;
const List = styled.ol`
padding: 0;
margin-top: 8px;
`;
const ListItem = styled.li`
font-size: 14px;
padding: 0;
list-style: none;
position: relative;
&:before {
content: "·";
color: ${(props) => props.theme.textTertiary};
position: absolute;
left: -8px;
}
`;
const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${(props) => props.theme.textTertiary};
&:hover {
color: ${(props) => props.theme.text};
}
`;
const RecentSearch = styled(Link)`
display: flex;
justify-content: space-between;
color: ${(props) => props.theme.textSecondary};
padding: 1px 4px;
border-radius: 4px;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.secondaryBackground};
${RemoveButton} {
opacity: 1;
}
}
`;
export default observer(RecentSearches);

View File

@@ -50,6 +50,8 @@ export default class BaseStore<T extends BaseModel> {
modelName: string;
apiEndpoint: string;
rootStore: RootStore;
actions = [
@@ -65,6 +67,10 @@ export default class BaseStore<T extends BaseModel> {
this.rootStore = rootStore;
this.model = model;
this.modelName = modelNameFromClassName(model.name);
if (!this.apiEndpoint) {
this.apiEndpoint = `${this.modelName}s`;
}
}
@action
@@ -125,7 +131,7 @@ export default class BaseStore<T extends BaseModel> {
this.isSaving = true;
try {
const res = await client.post(`/${this.modelName}s.create`, {
const res = await client.post(`/${this.apiEndpoint}.create`, {
...params,
...options,
});
@@ -150,7 +156,7 @@ export default class BaseStore<T extends BaseModel> {
this.isSaving = true;
try {
const res = await client.post(`/${this.modelName}s.update`, {
const res = await client.post(`/${this.apiEndpoint}.update`, {
...params,
...options,
});
@@ -172,7 +178,7 @@ export default class BaseStore<T extends BaseModel> {
this.isSaving = true;
try {
await client.post(`/${this.modelName}s.delete`, {
await client.post(`/${this.apiEndpoint}.delete`, {
id: item.id,
...options,
});
@@ -193,7 +199,7 @@ export default class BaseStore<T extends BaseModel> {
this.isFetching = true;
try {
const res = await client.post(`/${this.modelName}s.info`, {
const res = await client.post(`/${this.apiEndpoint}.info`, {
id,
});
invariant(res && res.data, "Data should be available");
@@ -219,7 +225,7 @@ export default class BaseStore<T extends BaseModel> {
this.isFetching = true;
try {
const res = await client.post(`/${this.modelName}s.list`, params);
const res = await client.post(`/${this.apiEndpoint}.list`, params);
invariant(res && res.data, "Data not available");
runInAction(`list#${this.modelName}`, () => {

View File

@@ -22,6 +22,16 @@ type FetchParams = PaginationParams & { collectionId: string };
type FetchPageParams = PaginationParams & { template?: boolean };
export type SearchParams = {
offset?: number;
limit?: number;
dateFilter?: DateFilter;
includeArchived?: boolean;
includeDrafts?: boolean;
collectionId?: string;
userId?: string;
};
type ImportOptions = {
publish?: boolean;
};
@@ -392,15 +402,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
search = async (
query: string,
options: {
offset?: number;
limit?: number;
dateFilter?: DateFilter;
includeArchived?: boolean;
includeDrafts?: boolean;
collectionId?: string;
userId?: string;
}
options: SearchParams
): Promise<SearchResult[]> => {
const compactedOptions = omitBy(options, (o) => !o);
const res = await client.get("/documents.search", {

View File

@@ -14,6 +14,7 @@ import MembershipsStore from "./MembershipsStore";
import NotificationSettingsStore from "./NotificationSettingsStore";
import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore";
import SearchesStore from "./SearchesStore";
import SharesStore from "./SharesStore";
import ToastsStore from "./ToastsStore";
import UiStore from "./UiStore";
@@ -36,6 +37,7 @@ export default class RootStore {
presence: DocumentPresenceStore;
policies: PoliciesStore;
revisions: RevisionsStore;
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
users: UsersStore;
@@ -60,6 +62,7 @@ export default class RootStore {
this.notificationSettings = new NotificationSettingsStore(this);
this.presence = new DocumentPresenceStore();
this.revisions = new RevisionsStore(this);
this.searches = new SearchesStore(this);
this.shares = new SharesStore(this);
this.ui = new UiStore();
this.users = new UsersStore(this);
@@ -83,6 +86,7 @@ export default class RootStore {
this.presence.clear();
this.policies.clear();
this.revisions.clear();
this.searches.clear();
this.shares.clear();
this.fileOperations.clear();
// this.ui omitted to keep ui settings between sessions

View File

@@ -0,0 +1,20 @@
import { uniqBy } from "lodash";
import { computed } from "mobx";
import SearchQuery from "~/models/SearchQuery";
import BaseStore, { RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class SearchesStore extends BaseStore<SearchQuery> {
actions = [RPCAction.List, RPCAction.Delete];
apiEndpoint = "searches";
constructor(rootStore: RootStore) {
super(rootStore, SearchQuery);
}
@computed
get recent(): SearchQuery[] {
return uniqBy(this.orderedData, "query").slice(0, 8);
}
}

View File

@@ -14,6 +14,7 @@ import "./collection";
import "./document";
import "./integration";
import "./notificationSetting";
import "./searchQuery";
import "./share";
import "./user";
import "./team";

View File

@@ -0,0 +1,8 @@
import { SearchQuery, User } from "@server/models";
import policy from "./policy";
const { allow } = policy;
allow(User, ["read", "delete"], SearchQuery, (user, searchQuery) => {
return user && user.id === searchQuery.userId;
});

View File

@@ -12,6 +12,7 @@ import presentMembership from "./membership";
import presentNotificationSetting from "./notificationSetting";
import presentPolicies from "./policy";
import presentRevision from "./revision";
import presentSearchQuery from "./searchQuery";
import presentShare from "./share";
import presentSlackAttachment from "./slackAttachment";
import presentTeam from "./team";
@@ -29,6 +30,7 @@ export {
presentRevision,
presentCollection,
presentShare,
presentSearchQuery,
presentTeam,
presentGroup,
presentIntegration,

View File

@@ -0,0 +1,7 @@
export default function present(searchQuery: any) {
return {
id: searchQuery.id,
query: searchQuery.query,
createdAt: searchQuery.createdAt,
};
}

View File

@@ -19,6 +19,7 @@ import apiWrapper from "./middlewares/apiWrapper";
import editor from "./middlewares/editor";
import notificationSettings from "./notificationSettings";
import revisions from "./revisions";
import searches from "./searches";
import shares from "./shares";
import team from "./team";
import users from "./users";
@@ -53,6 +54,7 @@ router.use("/", revisions.routes());
router.use("/", views.routes());
router.use("/", hooks.routes());
router.use("/", apiKeys.routes());
router.use("/", searches.routes());
router.use("/", shares.routes());
router.use("/", team.routes());
router.use("/", integrations.routes());

View File

@@ -0,0 +1,45 @@
import Router from "koa-router";
import auth from "@server/middlewares/authentication";
import { SearchQuery } from "@server/models";
import { presentSearchQuery } from "@server/presenters";
import { assertPresent } from "@server/validation";
import pagination from "./middlewares/pagination";
const router = new Router();
router.post("searches.list", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
const searches = await SearchQuery.findAll({
where: {
userId: user.id,
},
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: searches.map(presentSearchQuery),
};
});
router.post("searches.delete", auth(), async (ctx) => {
const { id, query } = ctx.body;
assertPresent(id || query, "id or query is required");
const { user } = ctx.state;
await SearchQuery.destroy({
where: {
...(id ? { id } : { query }),
userId: user.id,
},
});
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -11,7 +11,7 @@ import pagination from "./middlewares/pagination";
const Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
// @ts-expect-error ts-migrate(7030) FIXME: Not all code paths return a value.
router.post("shares.info", auth(), async (ctx) => {
const { id, documentId, apiVersion } = ctx.body;
assertUuid(id || documentId, "id or documentId is required");
@@ -40,7 +40,8 @@ router.post("shares.info", auth(), async (ctx) => {
// Deprecated API response returns just the share for the current documentId
if (apiVersion !== 2) {
if (!share || !share.document) {
return (ctx.response.status = 204);
ctx.response.status = 204;
return;
}
authorize(user, "read", share);
@@ -86,7 +87,8 @@ router.post("shares.info", auth(), async (ctx) => {
}
if (!shares.length) {
return (ctx.response.status = 204);
ctx.response.status = 204;
return;
}
ctx.body = {

View File

@@ -497,6 +497,8 @@
"Past week": "Past week",
"Past month": "Past month",
"Past year": "Past year",
"Recent searches": "Recent searches",
"Remove search": "Remove search",
"Active documents": "Active documents",
"Documents in collections you are able to access": "Documents in collections you are able to access",
"All documents": "All documents",
@@ -505,10 +507,7 @@
"Author": "Author",
"Not Found": "Not Found",
"We were unable to find the page youre looking for.": "We were unable to find the page youre looking for.",
"Use the <em>{{ shortcut }}</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ shortcut }}</em> shortcut to search from anywhere in your knowledge base",
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
"Create a new document?": "Create a new document?",
"Clear filters": "Clear filters",
"No documents found for your search filters.": "No documents found for your search filters.",
"Processing": "Processing",
"Expired": "Expired",
"Error": "Error",