diff --git a/app/models/SearchQuery.ts b/app/models/SearchQuery.ts new file mode 100644 index 000000000..56797e5b9 --- /dev/null +++ b/app/models/SearchQuery.ts @@ -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; diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index a75022211..9c4dbb535 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -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 { - firstDocument: React.Component | null | undefined; + firstDocument: HTMLAnchorElement | null | undefined; lastQuery = ""; - lastParams: Record; + lastParams: SearchParams; @observable query: string = decodeURIComponentSafe(this.props.match.params.term || ""); @@ -67,9 +65,6 @@ class Search extends React.Component { @observable isLoading = false; - @observable - pinToTop = !!this.props.match.params.term; - componentDidMount() { this.handleTermChange(); @@ -151,12 +146,6 @@ class Search extends React.Component { }); }; - 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 { 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(); }; @@ -225,7 +215,6 @@ class Search extends React.Component { 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 { this.isLoading = false; } } else { - this.pinToTop = false; this.isLoading = false; this.lastQuery = this.query; } @@ -252,17 +240,14 @@ class Search extends React.Component { }); }; - 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 ( @@ -277,28 +262,14 @@ class Search extends React.Component { )} - + - {showShortcutTip && ( - - - , - }} - /> - - - )} - {this.pinToTop && ( + + {this.query ? ( { } /> + ) : ( + )} {showEmpty && ( - - No documents found for your search filters.
-
- {can.createDocument && Create a new document?} + No documents found for your search filters.
- - {this.collectionId && - can.createDocument && - canCollection.update ? ( - - ) : ( - - )} -    - -
)} - + { } } -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)` diff --git a/app/scenes/Search/components/RecentSearches.tsx b/app/scenes/Search/components/RecentSearches.tsx new file mode 100644 index 000000000..cf0cf3f65 --- /dev/null +++ b/app/scenes/Search/components/RecentSearches.tsx @@ -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 ? ( + <> + {t("Recent searches")} + + {searches.recent.map((searchQuery) => ( + + + {searchQuery.query} + + { + ev.preventDefault(); + searchQuery.delete(); + }} + > + + + + + + ))} + + + ) : 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); diff --git a/app/stores/BaseStore.ts b/app/stores/BaseStore.ts index 8f0efd36a..f37eaaf11 100644 --- a/app/stores/BaseStore.ts +++ b/app/stores/BaseStore.ts @@ -50,6 +50,8 @@ export default class BaseStore { modelName: string; + apiEndpoint: string; + rootStore: RootStore; actions = [ @@ -65,6 +67,10 @@ export default class BaseStore { 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 { 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 { 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 { 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 { 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 { 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}`, () => { diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 5c95fc794..5530ba8ff 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -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 { @action search = async ( query: string, - options: { - offset?: number; - limit?: number; - dateFilter?: DateFilter; - includeArchived?: boolean; - includeDrafts?: boolean; - collectionId?: string; - userId?: string; - } + options: SearchParams ): Promise => { const compactedOptions = omitBy(options, (o) => !o); const res = await client.get("/documents.search", { diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index deb2ef24a..181ea3fe5 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -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 diff --git a/app/stores/SearchesStore.ts b/app/stores/SearchesStore.ts new file mode 100644 index 000000000..3f41b31fe --- /dev/null +++ b/app/stores/SearchesStore.ts @@ -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 { + 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); + } +} diff --git a/server/policies/index.ts b/server/policies/index.ts index fe5adfbac..4e99dcf9f 100644 --- a/server/policies/index.ts +++ b/server/policies/index.ts @@ -14,6 +14,7 @@ import "./collection"; import "./document"; import "./integration"; import "./notificationSetting"; +import "./searchQuery"; import "./share"; import "./user"; import "./team"; diff --git a/server/policies/searchQuery.ts b/server/policies/searchQuery.ts new file mode 100644 index 000000000..fa3901c97 --- /dev/null +++ b/server/policies/searchQuery.ts @@ -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; +}); diff --git a/server/presenters/index.ts b/server/presenters/index.ts index a537bbed0..6c4a8e812 100644 --- a/server/presenters/index.ts +++ b/server/presenters/index.ts @@ -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, diff --git a/server/presenters/searchQuery.ts b/server/presenters/searchQuery.ts new file mode 100644 index 000000000..cf1425494 --- /dev/null +++ b/server/presenters/searchQuery.ts @@ -0,0 +1,7 @@ +export default function present(searchQuery: any) { + return { + id: searchQuery.id, + query: searchQuery.query, + createdAt: searchQuery.createdAt, + }; +} diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 2e24754a4..0cb596640 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -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()); diff --git a/server/routes/api/searches.ts b/server/routes/api/searches.ts new file mode 100644 index 000000000..2ed905baa --- /dev/null +++ b/server/routes/api/searches.ts @@ -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; diff --git a/server/routes/api/shares.ts b/server/routes/api/shares.ts index e5e027af4..7858912cd 100644 --- a/server/routes/api/shares.ts +++ b/server/routes/api/shares.ts @@ -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 = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 78d50ea8a..0824b7ebb 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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 you’re looking for.": "We were unable to find the page you’re looking for.", - "Use the {{ shortcut }} shortcut to search from anywhere in your knowledge base": "Use the {{ shortcut }} shortcut to search from anywhere in your knowledge base", - "No documents found for your search filters. <1>": "No documents found for your search filters. <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",