diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index d566737d4..3fbab8362 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -19,14 +19,19 @@ import { githubIssuesUrl, } from "@shared/utils/urlHelpers"; import stores from "~/stores"; +import SearchQuery from "~/models/SearchQuery"; import KeyboardShortcuts from "~/scenes/KeyboardShortcuts"; import { createAction } from "~/actions"; -import { NavigationSection } from "~/actions/sections"; +import { + NavigationSection, + NoSection, + RecentSearchesSection, +} from "~/actions/sections"; import history from "~/utils/history"; import { settingsPath, homePath, - searchUrl, + searchPath, draftsPath, templatesPath, archivePath, @@ -42,14 +47,24 @@ export const navigateToHome = createAction({ visible: ({ location }) => location.pathname !== homePath(), }); -export const navigateToSearch = createAction({ - name: ({ t }) => t("Search"), - section: NavigationSection, - shortcut: ["/"], - icon: , - perform: () => history.push(searchUrl()), - visible: ({ location }) => location.pathname !== searchUrl(), -}); +export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) => + createAction({ + section: RecentSearchesSection, + name: searchQuery.query, + icon: , + perform: () => history.push(searchPath(searchQuery.query)), + }); + +export const navigateToSearchQuery = (searchQuery: string) => + createAction({ + id: "search", + section: NoSection, + name: ({ t }) => + t(`Search documents for "{{searchQuery}}"`, { searchQuery }), + icon: , + perform: () => history.push(searchPath(searchQuery)), + visible: ({ location }) => location.pathname !== searchPath(), + }); export const navigateToDrafts = createAction({ name: ({ t }) => t("Drafts"), @@ -70,6 +85,7 @@ export const navigateToTemplates = createAction({ export const navigateToArchive = createAction({ name: ({ t }) => t("Archive"), section: NavigationSection, + shortcut: ["g", "a"], icon: , perform: () => history.push(archivePath()), visible: ({ location }) => location.pathname !== archivePath(), @@ -145,7 +161,6 @@ export const logout = createAction({ export const rootNavigationActions = [ navigateToHome, - navigateToSearch, navigateToDrafts, navigateToTemplates, navigateToArchive, diff --git a/app/actions/index.ts b/app/actions/index.ts index 2084eeb25..3d271a98e 100644 --- a/app/actions/index.ts +++ b/app/actions/index.ts @@ -1,6 +1,6 @@ import { flattenDeep } from "lodash"; import * as React from "react"; -import { $Diff } from "utility-types"; +import { Optional } from "utility-types"; import { v4 as uuidv4 } from "uuid"; import { Action, @@ -10,17 +10,10 @@ import { MenuItemWithChildren, } from "~/types"; -export function createAction( - definition: $Diff< - Action, - { - id?: string; - } - > -): Action { +export function createAction(definition: Optional): Action { return { - id: uuidv4(), ...definition, + id: uuidv4(), }; } diff --git a/app/actions/sections.ts b/app/actions/sections.ts index e8a6a6320..a387d23a9 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -11,3 +11,8 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings"); export const NavigationSection = ({ t }: ActionContext) => t("Navigation"); export const UserSection = ({ t }: ActionContext) => t("People"); + +export const RecentSearchesSection = ({ t }: ActionContext) => + t("Recent searches"); + +export const NoSection = ""; diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 36219e605..12c31551e 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -11,7 +11,7 @@ import Sidebar from "~/components/Sidebar"; import SettingsSidebar from "~/components/Sidebar/Settings"; import history from "~/utils/history"; import { - searchUrl, + searchPath, matchDocumentSlug as slug, newDocumentPath, settingsPath, @@ -49,7 +49,7 @@ class AuthenticatedLayout extends React.Component { if (!ev.metaKey && !ev.ctrlKey) { ev.preventDefault(); ev.stopPropagation(); - history.push(searchUrl()); + history.push(searchPath()); } }; diff --git a/app/components/CommandBar.tsx b/app/components/CommandBar.tsx index 1e182ef8c..3239ea59e 100644 --- a/app/components/CommandBar.tsx +++ b/app/components/CommandBar.tsx @@ -1,24 +1,24 @@ import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar"; import { observer } from "mobx-react"; +import { QuestionMarkIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Portal } from "react-portal"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import CommandBarResults from "~/components/CommandBarResults"; +import SearchActions from "~/components/SearchActions"; import rootActions from "~/actions/root"; import useCommandBarActions from "~/hooks/useCommandBarActions"; +import useStores from "~/hooks/useStores"; import { CommandBarAction } from "~/types"; - -export const CommandBarOptions = { - animations: { - enterMs: 250, - exitMs: 200, - }, -}; +import { metaDisplay } from "~/utils/keyboard"; +import Text from "./Text"; function CommandBar() { const { t } = useTranslation(); + const { ui } = useStores(); + useCommandBarActions(rootActions); const { rootAction } = useKBar((state) => ({ @@ -30,20 +30,34 @@ function CommandBar() { })); return ( - - - - - - - - + <> + + + + + + + {ui.showModKHint && ( + + + {t( + "Open search from anywhere with the {{ shortcut }} shortcut", + { + shortcut: `${metaDisplay} + k`, + } + )} + + )} + + + + ); } @@ -59,6 +73,20 @@ function KBarPortal({ children }: { children: React.ReactNode }) { return {children}; } +const Hint = styled(Text)` + display: flex; + align-items: center; + gap: 4px; + background: ${(props) => props.theme.secondaryBackground}; + border-top: 1px solid ${(props) => props.theme.background}; + margin: 1px 0 0; + padding: 6px 16px; + width: 100%; + + position: absolute; + bottom: 0; +`; + const Positioner = styled(KBarPositioner)` z-index: ${(props) => props.theme.depths.commandBar}; `; diff --git a/app/components/CommandBarResults.tsx b/app/components/CommandBarResults.tsx index 7c2b59ee1..ad184b705 100644 --- a/app/components/CommandBarResults.tsx +++ b/app/components/CommandBarResults.tsx @@ -1,14 +1,18 @@ import { useMatches, KBarResults } from "kbar"; +import { orderBy } from "lodash"; import * as React from "react"; import styled from "styled-components"; import CommandBarItem from "~/components/CommandBarItem"; +import { NoSection } from "~/actions/sections"; export default function CommandBarResults() { const { results, rootActionId } = useMatches(); return ( + typeof item !== "string" && item.section === NoSection ? -1 : 1 + )} maxHeight={400} onRender={({ item, active }) => typeof item === "string" ? ( diff --git a/app/components/InputSearchPage.tsx b/app/components/InputSearchPage.tsx index a5d75be87..6fec91daf 100644 --- a/app/components/InputSearchPage.tsx +++ b/app/components/InputSearchPage.tsx @@ -7,7 +7,7 @@ import styled, { useTheme } from "styled-components"; import useBoolean from "~/hooks/useBoolean"; import useKeyDown from "~/hooks/useKeyDown"; import { isModKey } from "~/utils/keyboard"; -import { searchUrl } from "~/utils/routeHelpers"; +import { searchPath } from "~/utils/routeHelpers"; import Input from "./Input"; type Props = { @@ -51,7 +51,7 @@ function InputSearchPage({ if (ev.key === "Enter") { ev.preventDefault(); history.push( - searchUrl(ev.currentTarget.value, { + searchPath(ev.currentTarget.value, { collectionId, ref: source, }) diff --git a/app/components/SearchActions.ts b/app/components/SearchActions.ts new file mode 100644 index 000000000..b18620bce --- /dev/null +++ b/app/components/SearchActions.ts @@ -0,0 +1,26 @@ +import { useKBar } from "kbar"; +import * as React from "react"; +import { + navigateToRecentSearchQuery, + navigateToSearchQuery, +} from "~/actions/definitions/navigation"; +import useCommandBarActions from "~/hooks/useCommandBarActions"; +import useStores from "~/hooks/useStores"; + +export default function SearchActions() { + const { searches } = useStores(); + + React.useEffect(() => { + searches.fetchPage({}); + }, [searches]); + + const { searchQuery } = useKBar((state) => ({ + searchQuery: state.searchQuery, + })); + + useCommandBarActions(searchQuery ? [navigateToSearchQuery(searchQuery)] : []); + + useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery)); + + return null; +} diff --git a/app/components/Sidebar/Main.tsx b/app/components/Sidebar/Main.tsx index 9b2de6a65..9a60ee347 100644 --- a/app/components/Sidebar/Main.tsx +++ b/app/components/Sidebar/Main.tsx @@ -1,3 +1,4 @@ +import { useKBar } from "kbar"; import { observer } from "mobx-react"; import { EditIcon, @@ -10,6 +11,7 @@ import * as React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; +import { useHistory, useLocation } from "react-router-dom"; import styled from "styled-components"; import Bubble from "~/components/Bubble"; import Flex from "~/components/Flex"; @@ -21,10 +23,10 @@ import useStores from "~/hooks/useStores"; import AccountMenu from "~/menus/AccountMenu"; import { homePath, - searchUrl, draftsPath, templatesPath, settingsPath, + searchPath, } from "~/utils/routeHelpers"; import Sidebar from "./Sidebar"; import ArchiveLink from "./components/ArchiveLink"; @@ -38,9 +40,12 @@ import TrashLink from "./components/TrashLink"; function MainSidebar() { const { t } = useTranslation(); - const { policies, documents } = useStores(); + const { ui, policies, documents } = useStores(); const team = useCurrentTeam(); const user = useCurrentUser(); + const { query } = useKBar(); + const location = useLocation(); + const history = useHistory(); React.useEffect(() => { documents.fetchDrafts(); @@ -57,6 +62,16 @@ function MainSidebar() { ); const can = policies.abilities(team.id); + const handleSearch = React.useCallback(() => { + const isSearching = location.pathname.startsWith(searchPath()); + if (isSearching) { + history.push(searchPath()); + } else { + ui.enableModKHint(); + query.toggle(); + } + }, [ui, location, history, query]); + return ( {dndArea && ( @@ -81,12 +96,7 @@ function MainSidebar() { label={t("Home")} /> } label={t("Search")} exact={false} diff --git a/app/index.tsx b/app/index.tsx index 3b7758ad4..4bf59515a 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -8,7 +8,6 @@ import { Router } from "react-router-dom"; import { initI18n } from "@shared/i18n"; import stores from "~/stores"; import Analytics from "~/components/Analytics"; -import { CommandBarOptions } from "~/components/CommandBar"; import Dialogs from "~/components/Dialogs"; import ErrorBoundary from "~/components/ErrorBoundary"; import PageTheme from "~/components/PageTheme"; @@ -53,6 +52,17 @@ if ("serviceWorker" in window.navigator) { // Make sure to return the specific export containing the feature bundle. const loadFeatures = () => import("./utils/motion").then((res) => res.default); +const commandBarOptions = { + animations: { + enterMs: 250, + exitMs: 200, + }, + callbacks: { + onClose: () => stores.ui.disableModKHint(), + onQueryChange: () => stores.ui.disableModKHint(), + }, +}; + if (element) { const App = () => ( @@ -60,7 +70,7 @@ if (element) { - + <> diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index 9b21bdc1d..5e7cfcf0e 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -23,7 +23,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; import withStores from "~/components/withStores"; -import { searchUrl } from "~/utils/routeHelpers"; +import { searchPath } from "~/utils/routeHelpers"; import { decodeURIComponentSafe } from "~/utils/urls"; import CollectionFilter from "./components/CollectionFilter"; import DateFilter from "./components/DateFilter"; @@ -247,7 +247,7 @@ class Search extends React.Component { updateLocation = (query: string) => { this.props.history.replace({ - pathname: searchUrl(query), + pathname: searchPath(query), search: this.props.location.search, }); }; diff --git a/app/scenes/Search/components/RecentSearches.tsx b/app/scenes/Search/components/RecentSearches.tsx index d34937629..a88ea0a82 100644 --- a/app/scenes/Search/components/RecentSearches.tsx +++ b/app/scenes/Search/components/RecentSearches.tsx @@ -9,7 +9,7 @@ import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import useStores from "~/hooks/useStores"; import { hover } from "~/styles"; -import { searchUrl } from "~/utils/routeHelpers"; +import { searchPath } from "~/utils/routeHelpers"; function RecentSearches() { const { searches } = useStores(); @@ -26,7 +26,7 @@ function RecentSearches() { {searches.recent.map((searchQuery) => ( - + {searchQuery.query} { + this.showModKHint = true; + }; + + @action + disableModKHint = () => { + this.showModKHint = false; + }; + @action hideMobileSidebar = () => { this.mobileSidebarVisible = false; diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index c19c6dcaa..3b5b779f2 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -91,7 +91,7 @@ export function newDocumentPath( return `/collection/${collectionId}/new?${queryString.stringify(params)}`; } -export function searchUrl( +export function searchPath( query?: string, params: { collectionId?: string; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 762618d16..3c02aae5c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -26,6 +26,7 @@ "Create template": "Create template", "Home": "Home", "Search": "Search", + "Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"", "Drafts": "Drafts", "Templates": "Templates", "Archive": "Archive", @@ -49,6 +50,7 @@ "Document": "Document", "Navigation": "Navigation", "People": "People", + "Recent searches": "Recent searches", "currently editing": "currently editing", "currently viewing": "currently viewing", "previously edited": "previously edited", @@ -59,6 +61,7 @@ "Collapse": "Collapse", "Expand": "Expand", "Type a command or search": "Type a command or search", + "Open search from anywhere with the {{ shortcut }} shortcut": "Open search from anywhere with the {{ shortcut }} shortcut", "Server connection lost": "Server connection lost", "Edits you make will sync once you’re online": "Edits you make will sync once you’re online", "Submenu": "Submenu", @@ -364,11 +367,8 @@ "Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}", "Add people to {{ collectionName }}": "Add people to {{ collectionName }}", "Document updated by {{userName}}": "Document updated by {{userName}}", - "This template will be permanently deleted in <2> unless restored.": "This template will be permanently deleted in <2> unless restored.", - "This document will be permanently deleted in <2> unless restored.": "This document will be permanently deleted in <2> unless restored.", "You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?", "Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?", - "Untitled template": "Untitled template", "Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…", "Hide contents": "Hide contents", "Show contents": "Show contents", @@ -382,7 +382,10 @@ "Publish": "Publish", "Publishing": "Publishing", "Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document", - "You’re editing a template. Highlight some text and use the <2> control to add placeholders that can be filled out when creating new documents from this template.": "You’re editing a template. Highlight some text and use the <2> control to add placeholders that can be filled out when creating new documents from this template.", + "This template will be permanently deleted in <2> unless restored.": "This template will be permanently deleted in <2> unless restored.", + "This document will be permanently deleted in <2> unless restored.": "This document will be permanently deleted in <2> unless restored.", + "Highlight some text and use the <2> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <2> control to add placeholders that can be filled out when creating new documents", + "You’re editing a template": "You’re editing a template", "Archived by {{userName}}": "Archived by {{userName}}", "Deleted by {{userName}}": "Deleted by {{userName}}", "Observing {{ userName }}": "Observing {{ userName }}", @@ -506,7 +509,6 @@ "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",