From 33b6fbdee903346edf46751ee51041669b383c38 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 24 Oct 2021 12:30:27 -0700 Subject: [PATCH] feat: Command Bar (#2669) --- app/actions/definitions/collections.js | 67 +++++++ app/actions/definitions/debug.js | 33 ++++ app/actions/definitions/documents.js | 88 ++++++++++ app/actions/definitions/navigation.js | 159 +++++++++++++++++ app/actions/definitions/settings.js | 48 +++++ app/actions/definitions/users.js | 24 +++ app/actions/index.js | 117 +++++++++++++ app/actions/root.js | 16 ++ app/actions/sections.js | 14 ++ app/components/CommandBar.js | 87 ++++++++++ app/components/CommandBarItem.js | 60 +++++++ app/components/CommandBarResults.js | 44 +++++ app/components/ContextMenu/Template.js | 40 ++++- app/components/ContextMenu/index.js | 1 + app/components/Dialogs.js | 37 ++++ app/components/DocumentListItem.js | 4 +- app/components/HoverPreview.js | 2 - app/components/Layout.js | 63 +++---- app/components/Modal.js | 4 +- app/components/Popover.js | 2 - app/components/Scrollable.js | 22 ++- app/components/Sidebar/Main.js | 78 ++------- .../Sidebar/components/ArchiveLink.js | 5 +- .../Sidebar/components/Collections.js | 24 +-- .../Sidebar/components/SidebarAction.js | 44 +++++ .../Sidebar/components/TrashLink.js | 5 +- app/hooks/useCommandBarActions.js | 30 ++++ app/index.js | 31 ++-- app/menus/AccountMenu.js | 164 ++++-------------- app/menus/CollectionMenu.js | 6 +- app/menus/DocumentMenu.js | 4 +- app/menus/NewChildDocumentMenu.js | 6 +- app/menus/NewDocumentMenu.js | 4 +- app/menus/NewTemplateMenu.js | 4 +- app/menus/separator.js | 7 + app/scenes/Collection.js | 11 +- app/scenes/CollectionDelete.js | 4 +- app/scenes/CollectionEdit.js | 11 +- app/scenes/Document/components/Header.js | 4 +- .../Document/components/MultiplayerEditor.js | 4 +- app/scenes/GroupDelete.js | 4 +- app/scenes/Search/Search.js | 9 +- app/scenes/Settings/Features.js | 10 +- app/scenes/UserProfile.js | 4 +- app/stores/DialogsStore.js | 73 ++++++++ app/stores/RootStore.js | 3 + app/types/index.js | 101 ++++++++--- app/utils/history.js | 6 + app/utils/routeHelpers.js | 28 ++- docs/ARCHITECTURE.md | 1 + package.json | 4 +- shared/i18n/locales/en_US/translation.json | 66 ++++--- shared/theme.js | 10 +- shared/utils/routeHelpers.js | 12 +- yarn.lock | 64 ++++++- 55 files changed, 1373 insertions(+), 400 deletions(-) create mode 100644 app/actions/definitions/collections.js create mode 100644 app/actions/definitions/debug.js create mode 100644 app/actions/definitions/documents.js create mode 100644 app/actions/definitions/navigation.js create mode 100644 app/actions/definitions/settings.js create mode 100644 app/actions/definitions/users.js create mode 100644 app/actions/index.js create mode 100644 app/actions/root.js create mode 100644 app/actions/sections.js create mode 100644 app/components/CommandBar.js create mode 100644 app/components/CommandBarItem.js create mode 100644 app/components/CommandBarResults.js create mode 100644 app/components/Dialogs.js create mode 100644 app/components/Sidebar/components/SidebarAction.js create mode 100644 app/hooks/useCommandBarActions.js create mode 100644 app/menus/separator.js create mode 100644 app/stores/DialogsStore.js create mode 100644 app/utils/history.js diff --git a/app/actions/definitions/collections.js b/app/actions/definitions/collections.js new file mode 100644 index 00000000..51cddac6 --- /dev/null +++ b/app/actions/definitions/collections.js @@ -0,0 +1,67 @@ +// @flow +import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons"; +import * as React from "react"; +import stores from "stores"; +import CollectionEdit from "scenes/CollectionEdit"; +import CollectionNew from "scenes/CollectionNew"; +import DynamicCollectionIcon from "components/CollectionIcon"; +import { createAction } from "actions"; +import { CollectionSection } from "actions/sections"; +import history from "utils/history"; + +export const openCollection = createAction({ + name: ({ t }) => t("Open collection"), + section: CollectionSection, + shortcut: ["o", "c"], + icon: , + children: ({ stores }) => { + const collections = stores.collections.orderedData; + + return collections.map((collection) => ({ + id: collection.id, + name: collection.name, + icon: , + section: CollectionSection, + perform: () => history.push(collection.url), + })); + }, +}); + +export const createCollection = createAction({ + name: ({ t }) => t("New collection"), + section: CollectionSection, + icon: , + visible: ({ stores }) => + stores.policies.abilities(stores.auth.team?.id || "").createCollection, + perform: ({ t, event }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("Create a collection"), + content: , + }); + }, +}); + +export const editCollection = createAction({ + name: ({ t }) => t("Edit collection"), + section: CollectionSection, + icon: , + visible: ({ stores, activeCollectionId }) => + !!activeCollectionId && + stores.policies.abilities(activeCollectionId).update, + perform: ({ t, activeCollectionId }) => { + stores.dialogs.openModal({ + title: t("Edit collection"), + content: ( + + ), + }); + }, +}); + +export const rootCollectionActions = [openCollection, createCollection]; diff --git a/app/actions/definitions/debug.js b/app/actions/definitions/debug.js new file mode 100644 index 00000000..142f2735 --- /dev/null +++ b/app/actions/definitions/debug.js @@ -0,0 +1,33 @@ +// @flow +import { ToolsIcon, TrashIcon } from "outline-icons"; +import * as React from "react"; +import stores from "stores"; +import { createAction } from "actions"; +import { DebugSection } from "actions/sections"; +import env from "env"; +import { deleteAllDatabases } from "utils/developer"; + +export const clearIndexedDB = createAction({ + name: ({ t }) => t("Delete IndexedDB cache"), + icon: , + keywords: "cache clear database", + section: DebugSection, + perform: async ({ t }) => { + await deleteAllDatabases(); + stores.toasts.showToast(t("IndexedDB cache deleted")); + }, +}); + +export const development = createAction({ + name: ({ t }) => t("Development"), + keywords: "debug", + icon: , + iconInContextMenu: false, + section: DebugSection, + visible: ({ event }) => + env.ENVIRONMENT === "development" || + (event instanceof KeyboardEvent && event.altKey), + children: [clearIndexedDB], +}); + +export const rootDebugActions = [development]; diff --git a/app/actions/definitions/documents.js b/app/actions/definitions/documents.js new file mode 100644 index 00000000..2852b9dd --- /dev/null +++ b/app/actions/definitions/documents.js @@ -0,0 +1,88 @@ +// @flow +import { + StarredIcon, + DocumentIcon, + NewDocumentIcon, + ImportIcon, +} from "outline-icons"; +import * as React from "react"; +import { createAction } from "actions"; +import { DocumentSection } from "actions/sections"; +import getDataTransferFiles from "utils/getDataTransferFiles"; +import history from "utils/history"; +import { newDocumentPath } from "utils/routeHelpers"; + +export const openDocument = createAction({ + name: ({ t }) => t("Open document"), + section: DocumentSection, + shortcut: ["o", "d"], + icon: , + children: ({ stores }) => { + const paths = stores.collections.pathsToDocuments; + + return paths + .filter((path) => path.type === "document") + .map((path) => ({ + id: path.id, + name: path.title, + icon: () => + stores.documents.get(path.id)?.isStarred ? ( + + ) : undefined, + section: DocumentSection, + perform: () => history.push(path.url), + })); + }, +}); + +export const createDocument = createAction({ + name: ({ t }) => t("New document"), + section: DocumentSection, + icon: , + visible: ({ activeCollectionId, stores }) => + !!activeCollectionId && + stores.policies.abilities(activeCollectionId).update, + perform: ({ activeCollectionId }) => + activeCollectionId && history.push(newDocumentPath(activeCollectionId)), +}); + +export const importDocument = createAction({ + name: ({ t }) => t("Import document"), + section: DocumentSection, + icon: , + visible: ({ activeCollectionId, stores }) => + !!activeCollectionId && + stores.policies.abilities(activeCollectionId).update, + perform: ({ activeCollectionId, stores }) => { + const { documents, toasts } = stores; + + const input = document.createElement("input"); + input.type = "file"; + input.accept = documents.importFileTypes.join(", "); + input.onchange = async (ev: SyntheticEvent<>) => { + const files = getDataTransferFiles(ev); + + try { + const file = files[0]; + const document = await documents.import( + file, + null, + activeCollectionId, + { + publish: true, + } + ); + history.push(document.url); + } catch (err) { + toasts.showToast(err.message, { + type: "error", + }); + + throw err; + } + }; + input.click(); + }, +}); + +export const rootDocumentActions = [openDocument, importDocument]; diff --git a/app/actions/definitions/navigation.js b/app/actions/definitions/navigation.js new file mode 100644 index 00000000..db5d38cf --- /dev/null +++ b/app/actions/definitions/navigation.js @@ -0,0 +1,159 @@ +// @flow +import { + HomeIcon, + SearchIcon, + ArchiveIcon, + TrashIcon, + EditIcon, + OpenIcon, + SettingsIcon, + ShapesIcon, + KeyboardIcon, + EmailIcon, +} from "outline-icons"; +import * as React from "react"; +import { + developersUrl, + changelogUrl, + mailToUrl, + githubIssuesUrl, +} from "shared/utils/routeHelpers"; +import stores from "stores"; +import KeyboardShortcuts from "scenes/KeyboardShortcuts"; +import { createAction } from "actions"; +import { NavigationSection } from "actions/sections"; +import history from "utils/history"; +import { + settingsPath, + homePath, + searchUrl, + draftsPath, + templatesPath, + archivePath, + trashPath, +} from "utils/routeHelpers"; + +export const navigateToHome = createAction({ + name: ({ t }) => t("Home"), + section: NavigationSection, + shortcut: ["d"], + icon: , + perform: () => history.push(homePath()), + 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 navigateToDrafts = createAction({ + name: ({ t }) => t("Drafts"), + section: NavigationSection, + icon: , + perform: () => history.push(draftsPath()), + visible: ({ location }) => location.pathname !== draftsPath(), +}); + +export const navigateToTemplates = createAction({ + name: ({ t }) => t("Templates"), + section: NavigationSection, + icon: , + perform: () => history.push(templatesPath()), + visible: ({ location }) => location.pathname !== templatesPath(), +}); + +export const navigateToArchive = createAction({ + name: ({ t }) => t("Archive"), + section: NavigationSection, + icon: , + perform: () => history.push(archivePath()), + visible: ({ location }) => location.pathname !== archivePath(), +}); + +export const navigateToTrash = createAction({ + name: ({ t }) => t("Trash"), + section: NavigationSection, + icon: , + perform: () => history.push(trashPath()), + visible: ({ location }) => location.pathname !== trashPath(), +}); + +export const navigateToSettings = createAction({ + name: ({ t }) => t("Settings"), + section: NavigationSection, + shortcut: ["g", "s"], + iconInContextMenu: false, + icon: , + perform: () => history.push(settingsPath()), +}); + +export const openAPIDocumentation = createAction({ + name: ({ t }) => t("API documentation"), + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => window.open(developersUrl()), +}); + +export const openFeedbackUrl = createAction({ + name: ({ t }) => t("Send us feedback"), + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => window.open(mailToUrl()), +}); + +export const openBugReportUrl = createAction({ + name: ({ t }) => t("Report a bug"), + section: NavigationSection, + perform: () => window.open(githubIssuesUrl()), +}); + +export const openChangelog = createAction({ + name: ({ t }) => t("Changelog"), + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => window.open(changelogUrl()), +}); + +export const openKeyboardShortcuts = createAction({ + name: ({ t }) => t("Keyboard shortcuts"), + section: NavigationSection, + shortcut: ["?"], + iconInContextMenu: false, + icon: , + perform: ({ t }) => { + stores.dialogs.openGuide({ + title: t("Keyboard shortcuts"), + content: , + }); + }, +}); + +export const logout = createAction({ + name: ({ t }) => t("Log out"), + section: NavigationSection, + perform: () => stores.auth.logout(), +}); + +export const rootNavigationActions = [ + navigateToHome, + navigateToSearch, + navigateToDrafts, + navigateToTemplates, + navigateToArchive, + navigateToTrash, + navigateToSettings, + openAPIDocumentation, + openFeedbackUrl, + openBugReportUrl, + openChangelog, + openKeyboardShortcuts, + logout, +]; diff --git a/app/actions/definitions/settings.js b/app/actions/definitions/settings.js new file mode 100644 index 00000000..c2accae3 --- /dev/null +++ b/app/actions/definitions/settings.js @@ -0,0 +1,48 @@ +// @flow +import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons"; +import * as React from "react"; +import stores from "stores"; +import { createAction } from "actions"; +import { SettingsSection } from "actions/sections"; + +export const changeToDarkTheme = createAction({ + name: ({ t }) => t("Dark"), + icon: , + iconInContextMenu: false, + keywords: "theme dark night", + section: SettingsSection, + selected: () => stores.ui.theme === "dark", + perform: () => stores.ui.setTheme("dark"), +}); + +export const changeToLightTheme = createAction({ + name: ({ t }) => t("Light"), + icon: , + iconInContextMenu: false, + keywords: "theme light day", + section: SettingsSection, + selected: () => stores.ui.theme === "light", + perform: () => stores.ui.setTheme("light"), +}); + +export const changeToSystemTheme = createAction({ + name: ({ t }) => t("System"), + icon: , + iconInContextMenu: false, + keywords: "theme system default", + section: SettingsSection, + selected: () => stores.ui.theme === "system", + perform: () => stores.ui.setTheme("system"), +}); + +export const changeTheme = createAction({ + name: ({ t }) => t("Change theme"), + placeholder: ({ t }) => t("Change theme to"), + icon: () => + stores.ui.resolvedTheme === "light" ? : , + keywords: "appearance display", + section: SettingsSection, + children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme], +}); + +export const rootSettingsActions = [changeTheme]; diff --git a/app/actions/definitions/users.js b/app/actions/definitions/users.js new file mode 100644 index 00000000..d60f2e66 --- /dev/null +++ b/app/actions/definitions/users.js @@ -0,0 +1,24 @@ +// @flow +import { PlusIcon } from "outline-icons"; +import * as React from "react"; +import stores from "stores"; +import Invite from "scenes/Invite"; +import { createAction } from "actions"; +import { UserSection } from "actions/sections"; + +export const inviteUser = createAction({ + name: ({ t }) => `${t("Invite people")}…`, + icon: , + keywords: "team member user", + section: UserSection, + visible: ({ stores }) => + stores.policies.abilities(stores.auth.team?.id || "").inviteUser, + perform: ({ t }) => { + stores.dialogs.openModal({ + title: t("Invite people"), + content: , + }); + }, +}); + +export const rootUserActions = [inviteUser]; diff --git a/app/actions/index.js b/app/actions/index.js new file mode 100644 index 00000000..0e61d21f --- /dev/null +++ b/app/actions/index.js @@ -0,0 +1,117 @@ +// @flow +import { flattenDeep } from "lodash"; +import * as React from "react"; +import { v4 as uuidv4 } from "uuid"; +import type { + Action, + ActionContext, + CommandBarAction, + MenuItemClickable, + MenuItemWithChildren, +} from "types"; + +export function createAction( + definition: $Diff +): Action { + return { + id: uuidv4(), + ...definition, + }; +} + +export function actionToMenuItem( + action: Action, + context: ActionContext +): MenuItemClickable | MenuItemWithChildren { + function resolve(value: any): T { + if (typeof value === "function") { + return value(context); + } + + return value; + } + + const resolvedIcon = resolve>(action.icon); + const resolvedChildren = resolve(action.children); + + const visible = action.visible ? action.visible(context) : true; + const title = resolve(action.name); + const icon = + resolvedIcon && action.iconInContextMenu !== false + ? React.cloneElement(resolvedIcon, { color: "currentColor" }) + : undefined; + + if (resolvedChildren) { + return { + title, + icon, + items: resolvedChildren + .map((a) => actionToMenuItem(a, context)) + .filter((a) => !!a), + visible, + }; + } + + return { + title, + icon, + visible, + onClick: () => action.perform && action.perform(context), + selected: action.selected ? action.selected(context) : undefined, + }; +} + +export function actionToKBar( + action: Action, + context: ActionContext +): CommandBarAction[] { + function resolve(value: any): T { + if (typeof value === "function") { + return value(context); + } + + return value; + } + + if (typeof action.visible === "function" && !action.visible(context)) { + return []; + } + + const resolvedIcon = resolve>(action.icon); + const resolvedChildren = resolve(action.children); + const resolvedSection = resolve(action.section); + const resolvedName = resolve(action.name); + const resolvedPlaceholder = resolve(action.placeholder); + + const children = resolvedChildren + ? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter( + (a) => !!a + ) + : []; + + return [ + { + id: action.id, + name: resolvedName, + section: resolvedSection, + placeholder: resolvedPlaceholder, + keywords: `${action.keywords || ""} ${children + .filter((c) => !!c.keywords) + .map((c) => c.keywords) + .join(" ")}`, + shortcut: action.shortcut, + icon: resolvedIcon + ? React.cloneElement(resolvedIcon, { color: "currentColor" }) + : undefined, + perform: action.perform + ? () => action.perform && action.perform(context) + : undefined, + children: children.length ? children.map((a) => a.id) : undefined, + }, + ].concat( + children.map((child) => ({ + ...child, + parent: action.id, + })) + ); +} diff --git a/app/actions/root.js b/app/actions/root.js new file mode 100644 index 00000000..7be0013d --- /dev/null +++ b/app/actions/root.js @@ -0,0 +1,16 @@ +// @flow +import { rootCollectionActions } from "./definitions/collections"; +import { rootDebugActions } from "./definitions/debug"; +import { rootDocumentActions } from "./definitions/documents"; +import { rootNavigationActions } from "./definitions/navigation"; +import { rootSettingsActions } from "./definitions/settings"; +import { rootUserActions } from "./definitions/users"; + +export default [ + ...rootCollectionActions, + ...rootDocumentActions, + ...rootUserActions, + ...rootNavigationActions, + ...rootSettingsActions, + ...rootDebugActions, +]; diff --git a/app/actions/sections.js b/app/actions/sections.js new file mode 100644 index 00000000..97fc3ae7 --- /dev/null +++ b/app/actions/sections.js @@ -0,0 +1,14 @@ +// @flow +import { type ActionContext } from "types"; + +export const CollectionSection = ({ t }: ActionContext) => t("Collection"); + +export const DebugSection = ({ t }: ActionContext) => t("Debug"); + +export const DocumentSection = ({ t }: ActionContext) => t("Document"); + +export const SettingsSection = ({ t }: ActionContext) => t("Settings"); + +export const NavigationSection = ({ t }: ActionContext) => t("Navigation"); + +export const UserSection = ({ t }: ActionContext) => t("People"); diff --git a/app/components/CommandBar.js b/app/components/CommandBar.js new file mode 100644 index 00000000..7f82ca62 --- /dev/null +++ b/app/components/CommandBar.js @@ -0,0 +1,87 @@ +// @flow +import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Portal } from "react-portal"; +import styled from "styled-components"; +import CommandBarResults from "components/CommandBarResults"; +import rootActions from "actions/root"; +import useCommandBarActions from "hooks/useCommandBarActions"; + +export const CommandBarOptions = { + animations: { + enterMs: 250, + exitMs: 200, + }, +}; + +function CommandBar() { + const { t } = useTranslation(); + + useCommandBarActions(rootActions); + + const { rootAction } = useKBar((state) => ({ + rootAction: state.actions[state.currentRootActionId], + })); + + return ( + + + + + + + + + ); +} + +function KBarPortal({ children }: { children: React.Node }) { + const { showing } = useKBar((state) => ({ + showing: state.visualState !== "hidden", + })); + + if (!showing) { + return null; + } + + return {children}; +} + +const Positioner = styled(KBarPositioner)` + z-index: ${(props) => props.theme.depths.commandBar}; +`; + +const SearchInput = styled(KBarSearch)` + padding: 16px 20px; + width: 100%; + outline: none; + border: none; + background: ${(props) => props.theme.menuBackground}; + color: ${(props) => props.theme.text}; + + &:disabled, + &::placeholder { + color: ${(props) => props.theme.placeholder}; + } +`; + +const Animator = styled(KBarAnimator)` + max-width: 540px; + max-height: 75vh; + width: 90vw; + background: ${(props) => props.theme.menuBackground}; + color: ${(props) => props.theme.text}; + border-radius: 8px; + overflow: hidden; + box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px; +`; + +export default observer(CommandBar); diff --git a/app/components/CommandBarItem.js b/app/components/CommandBarItem.js new file mode 100644 index 00000000..8b304abe --- /dev/null +++ b/app/components/CommandBarItem.js @@ -0,0 +1,60 @@ +// @flow +import { BackIcon } from "outline-icons"; +import * as React from "react"; +import styled from "styled-components"; +import Flex from "components/Flex"; +import Key from "components/Key"; +import type { CommandBarAction } from "types"; + +type Props = {| + action: CommandBarAction, + active: Boolean, +|}; + +function CommandBarItem({ action, active }: Props, ref) { + return ( + + + + {action.icon ? ( + React.cloneElement(action.icon, { size: 22 }) + ) : ( + + )} + + {action.name} + {action.children?.length ? "…" : ""} + + {action.shortcut?.length ? ( +
+ {action.shortcut.map((sc) => ( + {sc} + ))} +
+ ) : null} +
+ ); +} + +const Icon = styled.div` + width: 22px; + height: 22px; + color: ${(props) => props.theme.textSecondary}; +`; + +const Item = styled.div` + font-size: 15px; + padding: 12px 16px; + background: ${(props) => + props.active ? props.theme.menuItemSelected : "none"}; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; +`; + +const ForwardIcon = styled(BackIcon)` + transform: rotate(180deg); +`; + +export default React.forwardRef(CommandBarItem); diff --git a/app/components/CommandBarResults.js b/app/components/CommandBarResults.js new file mode 100644 index 00000000..1af11c4e --- /dev/null +++ b/app/components/CommandBarResults.js @@ -0,0 +1,44 @@ +// @flow +import { useMatches, KBarResults, NO_GROUP } from "kbar"; +import * as React from "react"; +import styled from "styled-components"; +import CommandBarItem from "components/CommandBarItem"; + +export default function CommandBarResults() { + const matches = useMatches(); + const items = React.useMemo( + () => + matches + .reduce((acc, curr) => { + const { actions, name } = curr; + acc.push(name); + acc.push(...actions); + return acc; + }, []) + .filter((i) => i !== NO_GROUP), + [matches] + ); + + return ( + + typeof item === "string" ? ( +
{item}
+ ) : ( + + ) + } + /> + ); +} + +const Header = styled.h3` + font-size: 13px; + letter-spacing: 0.04em; + margin: 0; + padding: 16px 0 4px 20px; + color: ${(props) => props.theme.textTertiary}; + height: 36px; +`; diff --git a/app/components/ContextMenu/Template.js b/app/components/ContextMenu/Template.js index 95b8c5be..fc65db0a 100644 --- a/app/components/ContextMenu/Template.js +++ b/app/components/ContextMenu/Template.js @@ -2,7 +2,7 @@ import { ExpandedIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import { useMenuState, MenuButton, @@ -15,10 +15,20 @@ import Header from "./Header"; import MenuItem, { MenuAnchor } from "./MenuItem"; import Separator from "./Separator"; import ContextMenu from "."; -import { type MenuItem as TMenuItem } from "types"; +import { actionToMenuItem } from "actions"; +import useStores from "hooks/useStores"; +import type { + MenuItem as TMenuItem, + Action, + ActionContext, + MenuSeparator, + MenuHeading, +} from "types"; type Props = {| items: TMenuItem[], + actions: (Action | MenuSeparator | MenuHeading)[], + context?: $Shape, |}; const Disclosure = styled(ExpandedIcon)` @@ -68,8 +78,30 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] { return filtered; } -function Template({ items, ...menu }: Props): React.Node { - const filteredTemplates = filterTemplateItems(items); +function Template({ items, actions, context, ...menu }: Props): React.Node { + const { t } = useTranslation(); + const location = useLocation(); + const stores = useStores(); + const { ui } = stores; + + const ctx = { + t, + isCommandBar: false, + isContextMenu: true, + activeCollectionId: ui.activeCollectionId, + activeDocumentId: ui.activeDocumentId, + location, + stores, + ...context, + }; + + const filteredTemplates = filterTemplateItems( + actions + ? actions.map((action) => + action.type ? action : actionToMenuItem(action, ctx) + ) + : items + ); const iconIsPresentInAnyMenuItem = filteredTemplates.find( (item) => !item.type && !!item.icon ); diff --git a/app/components/ContextMenu/index.js b/app/components/ContextMenu/index.js index ba3be5e2..a601191f 100644 --- a/app/components/ContextMenu/index.js +++ b/app/components/ContextMenu/index.js @@ -123,6 +123,7 @@ export const Background = styled.div` border-radius: 6px; padding: 6px 0; min-width: 180px; + min-height: 44px; overflow: hidden; overflow-y: auto; max-height: 75vh; diff --git a/app/components/Dialogs.js b/app/components/Dialogs.js new file mode 100644 index 00000000..0f136cde --- /dev/null +++ b/app/components/Dialogs.js @@ -0,0 +1,37 @@ +// @flow +import { observer } from "mobx-react-lite"; +import * as React from "react"; +import Guide from "components/Guide"; +import Modal from "components/Modal"; +import useStores from "hooks/useStores"; + +function Dialogs() { + const { dialogs } = useStores(); + const { guide, modalStack } = dialogs; + + return ( + <> + {guide ? ( + + {guide.content} + + ) : undefined} + {[...modalStack].map(([id, modal]) => ( + dialogs.closeModal(id)} + title={modal.title} + > + {modal.content} + + ))} + + ); +} + +export default observer(Dialogs); diff --git a/app/components/DocumentListItem.js b/app/components/DocumentListItem.js index 78f04182..981a3053 100644 --- a/app/components/DocumentListItem.js +++ b/app/components/DocumentListItem.js @@ -20,7 +20,7 @@ import useCurrentTeam from "hooks/useCurrentTeam"; import useCurrentUser from "hooks/useCurrentUser"; import useStores from "hooks/useStores"; import DocumentMenu from "menus/DocumentMenu"; -import { newDocumentUrl } from "utils/routeHelpers"; +import { newDocumentPath } from "utils/routeHelpers"; type Props = {| document: Document, @@ -132,7 +132,7 @@ function DocumentListItem(props: Props, ref) { <>