}
exact={false}
label={t("Archive")}
diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js
index 2f49ec36..7fb64afd 100644
--- a/app/components/Sidebar/components/Collections.js
+++ b/app/components/Sidebar/components/Collections.js
@@ -1,7 +1,7 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
-import { PlusIcon, CollapsedIcon } from "outline-icons";
+import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@@ -12,15 +12,12 @@ import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import PlaceholderCollections from "./PlaceholderCollections";
+import SidebarAction from "./SidebarAction";
import SidebarLink from "./SidebarLink";
-import useCurrentTeam from "hooks/useCurrentTeam";
+import { createCollection } from "actions/definitions/collections";
import useToasts from "hooks/useToasts";
-type Props = {
- onCreateCollection: () => void,
-};
-
-function Collections({ onCreateCollection }: Props) {
+function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
@@ -28,9 +25,7 @@ function Collections({ onCreateCollection }: Props) {
const [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
- const team = useCurrentTeam();
const orderedCollections = collections.orderedData;
- const can = policies.abilities(team.id);
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@@ -93,16 +88,7 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
- {can.createCollection && (
-
}
- label={`${t("New collection")}…`}
- exact
- depth={0.5}
- />
- )}
+
>
);
diff --git a/app/components/Sidebar/components/SidebarAction.js b/app/components/Sidebar/components/SidebarAction.js
new file mode 100644
index 00000000..18768c1a
--- /dev/null
+++ b/app/components/Sidebar/components/SidebarAction.js
@@ -0,0 +1,44 @@
+// @flow
+import invariant from "invariant";
+import { observer } from "mobx-react";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { useLocation } from "react-router";
+import SidebarLink from "./SidebarLink";
+import { actionToMenuItem } from "actions";
+import useStores from "hooks/useStores";
+import type { Action } from "types";
+
+type Props = {|
+ action: Action,
+|};
+
+function SidebarAction({ action, ...rest }: Props) {
+ const stores = useStores();
+ const { t } = useTranslation();
+ const location = useLocation();
+
+ const context = {
+ isContextMenu: false,
+ isCommandBar: false,
+ activeCollectionId: undefined,
+ activeDocumentId: undefined,
+ location,
+ stores,
+ t,
+ };
+
+ const menuItem = actionToMenuItem(action, context);
+ invariant(menuItem.onClick, "passed action must have perform");
+
+ return (
+
+ );
+}
+
+export default observer(SidebarAction);
diff --git a/app/components/Sidebar/components/TrashLink.js b/app/components/Sidebar/components/TrashLink.js
index 687b2443..90a456c8 100644
--- a/app/components/Sidebar/components/TrashLink.js
+++ b/app/components/Sidebar/components/TrashLink.js
@@ -7,8 +7,9 @@ import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
-import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
+import useStores from "hooks/useStores";
+import { trashPath } from "utils/routeHelpers";
function TrashLink({ documents }) {
const { policies } = useStores();
@@ -33,7 +34,7 @@ function TrashLink({ documents }) {
<>
}
exact={false}
label={t("Trash")}
diff --git a/app/hooks/useCommandBarActions.js b/app/hooks/useCommandBarActions.js
new file mode 100644
index 00000000..2a76c872
--- /dev/null
+++ b/app/hooks/useCommandBarActions.js
@@ -0,0 +1,30 @@
+// @flow
+import { useRegisterActions } from "kbar";
+import { flattenDeep } from "lodash";
+import { useTranslation } from "react-i18next";
+import { useLocation } from "react-router-dom";
+import { actionToKBar } from "actions";
+import useStores from "hooks/useStores";
+import type { Action } from "types";
+
+export default function useCommandBarActions(actions: Action[]) {
+ const stores = useStores();
+ const { t } = useTranslation();
+ const location = useLocation();
+
+ const context = {
+ t,
+ isCommandBar: true,
+ isContextMenu: false,
+ activeCollectionId: stores.ui.activeCollectionId,
+ activeDocumentId: stores.ui.activeDocumentId,
+ location,
+ stores,
+ };
+
+ const registerable = flattenDeep(
+ actions.map((action) => actionToKBar(action, context))
+ );
+
+ useRegisterActions(registerable, [registerable.length, location.pathname]);
+}
diff --git a/app/index.js b/app/index.js
index 608a8fe6..4b2116c4 100644
--- a/app/index.js
+++ b/app/index.js
@@ -1,7 +1,7 @@
// @flow
import "focus-visible";
import { LazyMotion } from "framer-motion";
-import { createBrowserHistory } from "history";
+import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
@@ -9,19 +9,21 @@ 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";
import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
import Routes from "./routes";
+import history from "./utils/history";
import { initSentry } from "./utils/sentry";
import env from "env";
initI18n();
const element = window.document.getElementById("root");
-const history = createBrowserHistory();
if (env.SENTRY_DSN) {
initSentry(history);
@@ -61,17 +63,20 @@ if (element) {
-
-
- <>
-
-
-
-
-
- >
-
-
+
+
+
+ <>
+
+
+
+
+
+
+ >
+
+
+
diff --git a/app/menus/AccountMenu.js b/app/menus/AccountMenu.js
index a6dd053c..6c3e51a5 100644
--- a/app/menus/AccountMenu.js
+++ b/app/menus/AccountMenu.js
@@ -1,29 +1,27 @@
// @flow
import { observer } from "mobx-react";
-import { MoonIcon, SunIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
-import {
- changelog,
- developers,
- githubIssuesUrl,
- mailToUrl,
- settings,
-} from "shared/utils/routeHelpers";
-import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
-import Guide from "components/Guide";
-import env from "env";
-import useBoolean from "hooks/useBoolean";
+import { development } from "actions/definitions/debug";
+import {
+ navigateToSettings,
+ openKeyboardShortcuts,
+ openChangelog,
+ openAPIDocumentation,
+ openBugReportUrl,
+ openFeedbackUrl,
+ logout,
+} from "actions/definitions/navigation";
+import { changeTheme } from "actions/definitions/settings";
import useCurrentTeam from "hooks/useCurrentTeam";
import usePrevious from "hooks/usePrevious";
import useSessions from "hooks/useSessions";
import useStores from "hooks/useStores";
-import useToasts from "hooks/useToasts";
-import { deleteAllDatabases } from "utils/developer";
+import separator from "menus/separator";
type Props = {|
children: (props: any) => React.Node,
@@ -36,18 +34,11 @@ function AccountMenu(props: Props) {
placement: "bottom-start",
modal: true,
});
- const { showToast } = useToasts();
- const { auth, ui } = useStores();
- const { theme, resolvedTheme } = ui;
+ const { ui } = useStores();
+ const { theme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
- const [includeAlt, setIncludeAlt] = React.useState(false);
- const [
- keyboardShortcutsOpen,
- handleKeyboardShortcutsOpen,
- handleKeyboardShortcutsClose,
- ] = useBoolean();
React.useEffect(() => {
if (theme !== previousTheme) {
@@ -55,132 +46,43 @@ function AccountMenu(props: Props) {
}
}, [menu, theme, previousTheme]);
- const handleDeleteAllDatabases = React.useCallback(async () => {
- await deleteAllDatabases();
- showToast("IndexedDB cache deleted");
- menu.hide();
- }, [showToast, menu]);
-
- const handleOpenMenu = React.useCallback((event) => {
- setIncludeAlt(event.altKey);
- }, []);
-
- const items = React.useMemo(() => {
+ const actions = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
- {
- title: t("Settings"),
- to: settings(),
- },
- {
- title: t("Keyboard shortcuts"),
- onClick: handleKeyboardShortcutsOpen,
- },
- {
- title: t("API documentation"),
- href: developers(),
- },
- {
- type: "separator",
- },
- {
- title: t("Changelog"),
- href: changelog(),
- },
- {
- title: t("Send us feedback"),
- href: mailToUrl(),
- },
- {
- title: t("Report a bug"),
- href: githubIssuesUrl(),
- },
- ...(includeAlt || env.ENVIRONMENT === "development"
- ? [
- {
- title: t("Development"),
- items: [
- {
- title: "Delete IndexedDB cache",
- icon:
,
- onClick: handleDeleteAllDatabases,
- },
- ],
- },
- ]
- : []),
- {
- title: t("Appearance"),
- icon: resolvedTheme === "light" ?
:
,
- items: [
- {
- title: t("System"),
- onClick: () => ui.setTheme("system"),
- selected: theme === "system",
- },
- {
- title: t("Light"),
- onClick: () => ui.setTheme("light"),
- selected: theme === "light",
- },
- {
- title: t("Dark"),
- onClick: () => ui.setTheme("dark"),
- selected: theme === "dark",
- },
- ],
- },
- {
- type: "separator",
- },
+ navigateToSettings,
+ openKeyboardShortcuts,
+ openAPIDocumentation,
+ separator(),
+ openChangelog,
+ openFeedbackUrl,
+ openBugReportUrl,
+ development,
+ changeTheme,
+ separator(),
...(otherSessions.length
? [
{
- title: t("Switch team"),
- items: otherSessions.map((session) => ({
- title: session.name,
+ name: t("Switch team"),
+ children: otherSessions.map((session) => ({
+ name: session.name,
icon:
,
- href: session.url,
+ perform: () => (window.location.href = session.url),
})),
},
]
: []),
- {
- title: t("Log out"),
- onClick: auth.logout,
- },
+ logout,
];
- }, [
- auth.logout,
- team.id,
- team.url,
- sessions,
- handleKeyboardShortcutsOpen,
- handleDeleteAllDatabases,
- resolvedTheme,
- includeAlt,
- theme,
- t,
- ui,
- ]);
+ }, [team.id, team.url, sessions, t]);
return (
<>
-
-
-
-
- {props.children}
-
+
{props.children}
-
+
>
);
diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js
index 006a8956..853644bb 100644
--- a/app/menus/CollectionMenu.js
+++ b/app/menus/CollectionMenu.js
@@ -26,7 +26,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
-import { newDocumentUrl } from "utils/routeHelpers";
+import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
collection: Collection,
@@ -72,7 +72,7 @@ function CollectionMenu({
const handleNewDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
- history.push(newDocumentUrl(collection.id));
+ history.push(newDocumentPath(collection.id));
},
[history, collection.id]
);
@@ -225,7 +225,7 @@ function CollectionMenu({
>
setShowCollectionEdit(false)}
- collection={collection}
+ collectionId={collection.id}
/>
React.Node,
@@ -38,11 +38,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
/>
),
- to: newDocumentUrl(document.collectionId),
+ to: newDocumentPath(document.collectionId),
},
{
title: t("New nested document"),
- to: newDocumentUrl(document.collectionId, {
+ to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
},
diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js
index 1b17e6d8..15e7af40 100644
--- a/app/menus/NewDocumentMenu.js
+++ b/app/menus/NewDocumentMenu.js
@@ -13,7 +13,7 @@ import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
-import { newDocumentUrl } from "utils/routeHelpers";
+import { newDocumentPath } from "utils/routeHelpers";
function NewDocumentMenu() {
const menu = useMenuState({ modal: true });
@@ -29,7 +29,7 @@ function NewDocumentMenu() {
if (can.update) {
filtered.push({
- to: newDocumentUrl(collection.id),
+ to: newDocumentPath(collection.id),
title: {collection.name},
icon: ,
});
diff --git a/app/menus/NewTemplateMenu.js b/app/menus/NewTemplateMenu.js
index bbc0f39c..f3292d86 100644
--- a/app/menus/NewTemplateMenu.js
+++ b/app/menus/NewTemplateMenu.js
@@ -12,7 +12,7 @@ import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
-import { newDocumentUrl } from "utils/routeHelpers";
+import { newDocumentPath } from "utils/routeHelpers";
function NewTemplateMenu() {
const menu = useMenuState({ modal: true });
@@ -27,7 +27,7 @@ function NewTemplateMenu() {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
- to: newDocumentUrl(collection.id, { template: true }),
+ to: newDocumentPath(collection.id, { template: true }),
title: {collection.name},
icon: ,
});
diff --git a/app/menus/separator.js b/app/menus/separator.js
new file mode 100644
index 00000000..8b9834ba
--- /dev/null
+++ b/app/menus/separator.js
@@ -0,0 +1,7 @@
+// @flow
+
+export default function separator() {
+ return {
+ type: "separator",
+ };
+}
diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js
index bd1e5df7..a07b7ec5 100644
--- a/app/scenes/Collection.js
+++ b/app/scenes/Collection.js
@@ -39,13 +39,15 @@ import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
+import { editCollection } from "actions/definitions/collections";
import useBoolean from "hooks/useBoolean";
+import useCommandBarActions from "hooks/useCommandBarActions";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import CollectionMenu from "menus/CollectionMenu";
-import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
+import { newDocumentPath, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
@@ -109,6 +111,8 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
+ useCommandBarActions([editCollection]);
+
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
@@ -158,7 +162,7 @@ function CollectionScene() {
>
}
>
@@ -227,7 +231,7 @@ function CollectionScene() {
{canUser.createDocument && (
-
+
}>
{t("Create a document")}
@@ -388,6 +392,7 @@ const DropMessage = styled(HelpText)`
`;
const DropzoneContainer = styled.div`
+ outline-color: transparent !important;
min-height: calc(100% - 56px);
position: relative;
diff --git a/app/scenes/CollectionDelete.js b/app/scenes/CollectionDelete.js
index dbc91dd0..321e936d 100644
--- a/app/scenes/CollectionDelete.js
+++ b/app/scenes/CollectionDelete.js
@@ -8,7 +8,7 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
-import { homeUrl } from "utils/routeHelpers";
+import { homePath } from "utils/routeHelpers";
type Props = {
collection: Collection,
@@ -28,7 +28,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
try {
await collection.delete();
- history.push(homeUrl());
+ history.push(homePath());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js
index 904532fc..6ca7efd4 100644
--- a/app/scenes/CollectionEdit.js
+++ b/app/scenes/CollectionEdit.js
@@ -1,23 +1,28 @@
// @flow
+import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
-import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputSelect from "components/InputSelect";
+import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
- collection: Collection,
+ collectionId: string,
onSubmit: () => void,
};
-const CollectionEdit = ({ collection, onSubmit }: Props) => {
+const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
+ const { collections } = useStores();
+ const collection = collections.get(collectionId);
+ invariant(collection, "Collection not found");
+
const [name, setName] = useState(collection.name);
const [icon, setIcon] = useState(collection.icon);
const [color, setColor] = useState(collection.color || "#4E5C6E");
diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js
index 8dd4a454..b5eb43e0 100644
--- a/app/scenes/Document/components/Header.js
+++ b/app/scenes/Document/components/Header.js
@@ -31,7 +31,7 @@ import TableOfContentsMenu from "menus/TableOfContentsMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
-import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
+import { newDocumentPath, editDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
@@ -255,7 +255,7 @@ function DocumentHeader({
}
as={Link}
- to={newDocumentUrl(document.collectionId, {
+ to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
primary
diff --git a/app/scenes/Document/components/MultiplayerEditor.js b/app/scenes/Document/components/MultiplayerEditor.js
index 1a20ec9e..2eed797f 100644
--- a/app/scenes/Document/components/MultiplayerEditor.js
+++ b/app/scenes/Document/components/MultiplayerEditor.js
@@ -14,7 +14,7 @@ import usePageVisibility from "hooks/usePageVisibility";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
-import { homeUrl } from "utils/routeHelpers";
+import { homePath } from "utils/routeHelpers";
type Props = {|
...EditorProps,
@@ -61,7 +61,7 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
)
);
- history.replace(homeUrl());
+ history.replace(homePath());
});
provider.on("awarenessChange", ({ states }) => {
diff --git a/app/scenes/GroupDelete.js b/app/scenes/GroupDelete.js
index a349fa0d..fb78ae92 100644
--- a/app/scenes/GroupDelete.js
+++ b/app/scenes/GroupDelete.js
@@ -3,12 +3,12 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
-import { groupSettings } from "shared/utils/routeHelpers";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
+import { groupSettingsPath } from "utils/routeHelpers";
type Props = {|
group: Group,
@@ -27,7 +27,7 @@ function GroupDelete({ group, onSubmit }: Props) {
try {
await group.delete();
- history.push(groupSettings());
+ history.push(groupSettingsPath());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js
index 513a525f..7c2e3732 100644
--- a/app/scenes/Search/Search.js
+++ b/app/scenes/Search/Search.js
@@ -36,8 +36,7 @@ import StatusFilter from "./components/StatusFilter";
import UserFilter from "./components/UserFilter";
import NewDocumentMenu from "menus/NewDocumentMenu";
import { type LocationWithState } from "types";
-import { metaDisplay } from "utils/keyboard";
-import { newDocumentUrl, searchUrl } from "utils/routeHelpers";
+import { newDocumentPath, searchUrl } from "utils/routeHelpers";
import { decodeURIComponentSafe } from "utils/urls";
type Props = {
@@ -153,7 +152,7 @@ class Search extends React.Component {
handleNewDoc = () => {
if (this.collectionId) {
- this.props.history.push(newDocumentUrl(this.collectionId));
+ this.props.history.push(newDocumentPath(this.collectionId));
}
};
@@ -289,8 +288,8 @@ class Search extends React.Component {
}}
/>
diff --git a/app/scenes/Settings/Features.js b/app/scenes/Settings/Features.js
index 8a87249c..dc6e1e5d 100644
--- a/app/scenes/Settings/Features.js
+++ b/app/scenes/Settings/Features.js
@@ -49,7 +49,7 @@ function Features() {
Manage optional and beta features. Changing these settings will affect
- all team members.
+ the experience for all team members.
+ When enabled multiple people can edit documents at the same time.
+ Please note that this feature is in beta and currently disables
+ updating the document via the API.
+
+ }
/>
);
diff --git a/app/scenes/UserProfile.js b/app/scenes/UserProfile.js
index 7f38974f..02c94a78 100644
--- a/app/scenes/UserProfile.js
+++ b/app/scenes/UserProfile.js
@@ -6,7 +6,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
-import { settings } from "shared/utils/routeHelpers";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
@@ -18,6 +17,7 @@ import PaginatedDocumentList from "components/PaginatedDocumentList";
import Subheading from "components/Subheading";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
+import { settingsPath } from "utils/routeHelpers";
type Props = {|
user: User,
@@ -61,7 +61,7 @@ function UserProfile(props: Props) {
{isCurrentUser && (