diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx
index a3f8917e3..193a13645 100644
--- a/app/actions/definitions/documents.tsx
+++ b/app/actions/definitions/documents.tsx
@@ -42,6 +42,7 @@ import {
homePath,
newDocumentPath,
searchPath,
+ documentPath,
} from "~/utils/routeHelpers";
export const openDocument = createAction({
@@ -86,6 +87,48 @@ export const createDocument = createAction({
}),
});
+export const createDocumentFromTemplate = createAction({
+ name: ({ t }) => t("New from template"),
+ analyticsName: "New document",
+ section: DocumentSection,
+ icon: ,
+ keywords: "create",
+ visible: ({ currentTeamId, activeDocumentId, stores }) =>
+ !!currentTeamId &&
+ !!activeDocumentId &&
+ stores.policies.abilities(currentTeamId).createDocument &&
+ !stores.documents.get(activeDocumentId)?.template,
+ perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
+ history.push(
+ newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
+ {
+ starred: inStarredSection,
+ }
+ ),
+});
+
+export const createNestedDocument = createAction({
+ name: ({ t }) => t("New nested document"),
+ analyticsName: "New document",
+ section: DocumentSection,
+ icon: ,
+ keywords: "create",
+ visible: ({ currentTeamId, activeDocumentId, stores }) =>
+ !!currentTeamId &&
+ !!activeDocumentId &&
+ stores.policies.abilities(currentTeamId).createDocument &&
+ stores.policies.abilities(activeDocumentId).createChildDocument,
+ perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
+ history.push(
+ newDocumentPath(activeCollectionId, {
+ parentDocumentId: activeDocumentId,
+ }),
+ {
+ starred: inStarredSection,
+ }
+ ),
+});
+
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -165,9 +208,14 @@ export const publishDocument = createAction({
await document.save(undefined, {
publish: true,
});
- stores.toasts.showToast(t("Document published"), {
- type: "success",
- });
+ stores.toasts.showToast(
+ t("Published {{ documentName }}", {
+ documentName: document.noun,
+ }),
+ {
+ type: "success",
+ }
+ );
} else if (document) {
stores.dialogs.openModal({
title: t("Publish document"),
@@ -195,12 +243,20 @@ export const unpublishDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
+ if (!document) {
+ return;
+ }
- await document?.unpublish();
+ await document.unpublish();
- stores.toasts.showToast(t("Document unpublished"), {
- type: "success",
- });
+ stores.toasts.showToast(
+ t("Unpublished {{ documentName }}", {
+ documentName: document.noun,
+ }),
+ {
+ type: "success",
+ }
+ );
},
});
@@ -366,7 +422,7 @@ export const duplicateDocument = createAction({
invariant(document, "Document must exist");
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
- history.push(duped.url);
+ history.push(documentPath(duped));
stores.toasts.showToast(t("Document duplicated"), {
type: "success",
});
@@ -775,7 +831,16 @@ export const openDocumentInsights = createAction({
icon: ,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
- return !!activeDocumentId && can.read;
+ const document = activeDocumentId
+ ? stores.documents.get(activeDocumentId)
+ : undefined;
+
+ return (
+ !!activeDocumentId &&
+ can.read &&
+ !document?.isTemplate &&
+ !document?.isDeleted
+ );
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx
index d2f0c6dda..14e5c86f4 100644
--- a/app/actions/definitions/navigation.tsx
+++ b/app/actions/definitions/navigation.tsx
@@ -6,12 +6,12 @@ import {
EditIcon,
OpenIcon,
SettingsIcon,
- ShapesIcon,
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
BrowserIcon,
+ ShapesIcon,
} from "outline-icons";
import * as React from "react";
import { isMac } from "@shared/utils/browser";
@@ -33,7 +33,6 @@ import {
homePath,
searchPath,
draftsPath,
- templatesPath,
archivePath,
trashPath,
settingsPath,
@@ -67,15 +66,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
-export const navigateToTemplates = createAction({
- name: ({ t }) => t("Templates"),
- analyticsName: "Navigate to templates",
- section: NavigationSection,
- icon: ,
- perform: () => history.push(templatesPath()),
- visible: ({ location }) => location.pathname !== templatesPath(),
-});
-
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
@@ -103,7 +93,7 @@ export const navigateToSettings = createAction({
icon: ,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update,
- perform: () => history.push(settingsPath("details")),
+ perform: () => history.push(settingsPath()),
});
export const navigateToProfileSettings = createAction({
@@ -115,6 +105,15 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(settingsPath()),
});
+export const navigateToTemplateSettings = createAction({
+ name: ({ t }) => t("Templates"),
+ analyticsName: "Navigate to template settings",
+ section: NavigationSection,
+ iconInContextMenu: false,
+ icon: ,
+ perform: () => history.push(settingsPath("templates")),
+});
+
export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to notification settings",
@@ -216,7 +215,6 @@ export const logout = createAction({
export const rootNavigationActions = [
navigateToHome,
navigateToDrafts,
- navigateToTemplates,
navigateToArchive,
navigateToTrash,
downloadApp,
diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx
index 21886b631..974c7cb42 100644
--- a/app/components/DocumentBreadcrumb.tsx
+++ b/app/components/DocumentBreadcrumb.tsx
@@ -12,7 +12,7 @@ import { MenuInternalLink } from "~/types";
import {
archivePath,
collectionPath,
- templatesPath,
+ settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
@@ -44,12 +44,12 @@ function useCategory(document: Document): MenuInternalLink | null {
};
}
- if (document.isTemplate) {
+ if (document.template) {
return {
type: "route",
icon: ,
title: t("Templates"),
- to: templatesPath(),
+ to: settingsPath("templates"),
};
}
diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx
index 43d6b28a1..9f220f76e 100644
--- a/app/components/DocumentListItem.tsx
+++ b/app/components/DocumentListItem.tsx
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
-import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -9,7 +8,6 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
-import Button from "~/components/Button";
import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
@@ -18,12 +16,10 @@ import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
-import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
-import usePolicy from "~/hooks/usePolicy";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
-import { newDocumentPath } from "~/utils/routeHelpers";
+import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
@@ -52,7 +48,6 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
- const team = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -72,8 +67,6 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
- const can = usePolicy(team);
- const canCollection = usePolicy(document.collectionId);
return (
- {document.isTemplate &&
- !document.isArchived &&
- !document.isDeleted &&
- can.createDocument &&
- canCollection.update && (
- <>
- }
- neutral
- >
- {t("New doc")}
-
-
- >
- )}
& {
[x: string]: string | undefined;
}>
| boolean
- | null
+ | null,
+ location: LocationDescriptorObject
) => React.ReactNode;
+ /**
+ * If true, the tab will only be active if the path matches exactly.
+ */
exact?: boolean;
+ /**
+ * CSS properties to apply to the link when it is active.
+ */
activeStyle?: React.CSSProperties;
+ /**
+ * The path to match against the current location.
+ */
to: LocationDescriptor;
};
@@ -25,7 +35,10 @@ function NavLinkWithChildrenFunc(
{({ match, location }) => (
{children
- ? children(rest.isActive ? rest.isActive(match, location) : match)
+ ? children(
+ rest.isActive ? rest.isActive(match, location) : match,
+ location
+ )
: null}
)}
diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx
index 97fd3841e..00c8949d4 100644
--- a/app/components/Sidebar/App.tsx
+++ b/app/components/Sidebar/App.tsx
@@ -1,11 +1,5 @@
import { observer } from "mobx-react";
-import {
- EditIcon,
- SearchIcon,
- ShapesIcon,
- HomeIcon,
- SidebarIcon,
-} from "outline-icons";
+import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
@@ -21,12 +15,7 @@ import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { metaDisplay } from "~/utils/keyboard";
-import {
- homePath,
- draftsPath,
- templatesPath,
- searchPath,
-} from "~/utils/routeHelpers";
+import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
@@ -52,7 +41,6 @@ function AppSidebar() {
React.useEffect(() => {
if (!user.isViewer) {
void documents.fetchDrafts();
- void documents.fetchTemplates();
}
}, [documents, user.isViewer]);
@@ -138,19 +126,6 @@ function AppSidebar() {
{can.createDocument && (
<>
- }
- exact={false}
- label={t("Templates")}
- active={
- documents.active
- ? documents.active.isTemplate &&
- !documents.active.isDeleted &&
- !documents.active.isArchived
- : undefined
- }
- />
>
diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx
index 60b7cce2f..fe4cc7de5 100644
--- a/app/components/Sidebar/Settings.tsx
+++ b/app/components/Sidebar/Settings.tsx
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import { BackIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import { useHistory } from "react-router-dom";
+import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
@@ -11,6 +11,7 @@ import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import { metaDisplay } from "~/utils/keyboard";
+import { settingsPath } from "~/utils/routeHelpers";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
@@ -25,6 +26,7 @@ function SettingsSidebar() {
const { ui } = useStores();
const { t } = useTranslation();
const history = useHistory();
+ const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
@@ -62,6 +64,11 @@ function SettingsSidebar() {
}
label={item.name}
/>
diff --git a/app/components/Tab.tsx b/app/components/Tab.tsx
index 71774a1ff..ff2400bc4 100644
--- a/app/components/Tab.tsx
+++ b/app/components/Tab.tsx
@@ -1,4 +1,7 @@
import { m } from "framer-motion";
+import { LocationDescriptor } from "history";
+import isEqual from "lodash/isEqual";
+import queryString from "query-string";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
@@ -6,8 +9,19 @@ import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
type Props = Omit, "children"> & {
- to: string;
+ /**
+ * The path to match against the current location.
+ */
+ to: LocationDescriptor;
+ /**
+ * If true, the tab will only be active if the path matches exactly.
+ */
exact?: boolean;
+ /**
+ * If true, the tab will only be active if the query string matches exactly.
+ * By default query string parameters are ignored for location mathing.
+ */
+ exactQueryString?: boolean;
children?: React.ReactNode;
};
@@ -45,24 +59,38 @@ const transition = {
damping: 30,
};
-const Tab: React.FC = ({ children, ...rest }: Props) => {
+const Tab: React.FC = ({
+ children,
+ exact,
+ exactQueryString,
+ ...rest
+}: Props) => {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
return (
-
- {(match) => (
+
+ {(match, location) => (
<>
{children}
- {match && (
-
- )}
+ {match &&
+ (!exactQueryString ||
+ isEqual(
+ queryString.parse(location.search ?? ""),
+ queryString.parse(rest.to.search as string)
+ )) && (
+
+ )}
>
)}
diff --git a/app/hooks/useImportDocument.ts b/app/hooks/useImportDocument.ts
index ddd1d4d8e..5c0cb4607 100644
--- a/app/hooks/useImportDocument.ts
+++ b/app/hooks/useImportDocument.ts
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
+import { documentPath } from "~/utils/routeHelpers";
let importingLock = false;
@@ -50,7 +51,7 @@ export default function useImportDocument(
});
if (redirect) {
- history.push(doc.url);
+ history.push(documentPath(doc));
}
}
} catch (err) {
diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts
index 34e5499f1..57859ed74 100644
--- a/app/hooks/useSettingsConfig.ts
+++ b/app/hooks/useSettingsConfig.ts
@@ -12,38 +12,43 @@ import {
SettingsIcon,
ExportIcon,
ImportIcon,
+ ShapesIcon,
+ Icon,
} from "outline-icons";
-import React from "react";
+import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
-import ApiKeys from "~/scenes/Settings/ApiKeys";
-import Details from "~/scenes/Settings/Details";
-import Export from "~/scenes/Settings/Export";
-import Features from "~/scenes/Settings/Features";
-import GoogleAnalytics from "~/scenes/Settings/GoogleAnalytics";
-import Groups from "~/scenes/Settings/Groups";
-import Import from "~/scenes/Settings/Import";
-import Members from "~/scenes/Settings/Members";
-import Notifications from "~/scenes/Settings/Notifications";
-import Preferences from "~/scenes/Settings/Preferences";
-import Profile from "~/scenes/Settings/Profile";
-import Security from "~/scenes/Settings/Security";
-import SelfHosted from "~/scenes/Settings/SelfHosted";
-import Shares from "~/scenes/Settings/Shares";
-import Zapier from "~/scenes/Settings/Zapier";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import PluginLoader from "~/utils/PluginLoader";
import isCloudHosted from "~/utils/isCloudHosted";
+import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
+const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
+const Details = lazy(() => import("~/scenes/Settings/Details"));
+const Export = lazy(() => import("~/scenes/Settings/Export"));
+const Features = lazy(() => import("~/scenes/Settings/Features"));
+const GoogleAnalytics = lazy(() => import("~/scenes/Settings/GoogleAnalytics"));
+const Groups = lazy(() => import("~/scenes/Settings/Groups"));
+const Import = lazy(() => import("~/scenes/Settings/Import"));
+const Members = lazy(() => import("~/scenes/Settings/Members"));
+const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
+const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
+const Profile = lazy(() => import("~/scenes/Settings/Profile"));
+const Security = lazy(() => import("~/scenes/Settings/Security"));
+const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
+const Shares = lazy(() => import("~/scenes/Settings/Shares"));
+const Templates = lazy(() => import("~/scenes/Settings/Templates"));
+const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
+
export type ConfigItem = {
name: string;
path: string;
- icon: React.FC;
- component: React.ComponentType;
+ icon: React.FC>;
+ component: React.ComponentType;
enabled: boolean;
group: string;
};
@@ -55,6 +60,7 @@ const useSettingsConfig = () => {
const config = React.useMemo(() => {
const items: ConfigItem[] = [
+ // Account
{
name: t("Profile"),
path: settingsPath(),
@@ -87,7 +93,7 @@ const useSettingsConfig = () => {
group: t("Account"),
icon: CodeIcon,
},
- // Team group
+ // Workspace
{
name: t("Details"),
path: settingsPath("details"),
@@ -128,6 +134,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: GroupIcon,
},
+ {
+ name: t("Templates"),
+ path: settingsPath("templates"),
+ component: Templates,
+ enabled: true,
+ group: t("Workspace"),
+ icon: ShapesIcon,
+ },
{
name: t("Shared Links"),
path: settingsPath("shares"),
@@ -152,6 +166,7 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: ExportIcon,
},
+ // Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
@@ -190,6 +205,7 @@ const useSettingsConfig = () => {
const item = {
name: t(plugin.config.name),
path: integrationSettingsPath(plugin.id),
+ // TODO: Remove hardcoding of plugin id here
group: plugin.id === "collections" ? t("Workspace") : t("Integrations"),
component: plugin.settings,
enabled: enabledInDeployment && hasSettings && can.update,
diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx
index 94806a293..af37db970 100644
--- a/app/menus/DocumentMenu.tsx
+++ b/app/menus/DocumentMenu.tsx
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
-import { EditIcon, NewDocumentIcon, RestoreIcon } from "outline-icons";
+import { EditIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -38,6 +38,8 @@ import {
unpublishDocument,
printDocument,
openDocumentComments,
+ createDocumentFromTemplate,
+ createNestedDocument,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -47,7 +49,7 @@ import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
-import { documentEditPath, newDocumentPath } from "~/utils/routeHelpers";
+import { documentEditPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -266,15 +268,7 @@ function DocumentMenu({
visible: !!can.update && user.separateEditMode,
icon: ,
},
- {
- type: "route",
- title: t("New nested document"),
- to: newDocumentPath(document.collectionId, {
- parentDocumentId: document.id,
- }),
- visible: !!can.createChildDocument,
- icon: ,
- },
+ actionToMenuItem(createNestedDocument, context),
actionToMenuItem(importDocument, context),
actionToMenuItem(createTemplate, context),
actionToMenuItem(duplicateDocument, context),
@@ -283,6 +277,7 @@ function DocumentMenu({
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(pinDocument, context),
+ actionToMenuItem(createDocumentFromTemplate, context),
{
type: "separator",
},
diff --git a/app/menus/NewTemplateMenu.tsx b/app/menus/NewTemplateMenu.tsx
index 188d44216..defe590ac 100644
--- a/app/menus/NewTemplateMenu.tsx
+++ b/app/menus/NewTemplateMenu.tsx
@@ -14,7 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
-import { newDocumentPath } from "~/utils/routeHelpers";
+import { newTemplatePath } from "~/utils/routeHelpers";
function NewTemplateMenu() {
const menu = useMenuState({
@@ -24,6 +24,11 @@ function NewTemplateMenu() {
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
+ React.useEffect(() => {
+ void collections.fetchPage({
+ limit: 100,
+ });
+ }, [collections]);
const items = React.useMemo(
() =>
@@ -33,9 +38,7 @@ function NewTemplateMenu() {
if (can.update) {
filtered.push({
type: "route",
- to: newDocumentPath(collection.id, {
- template: true,
- }),
+ to: newTemplatePath(collection.id),
title: {collection.name},
icon: ,
});
diff --git a/app/menus/TemplatesMenu.tsx b/app/menus/TemplatesMenu.tsx
index af59d91fb..6d4937410 100644
--- a/app/menus/TemplatesMenu.tsx
+++ b/app/menus/TemplatesMenu.tsx
@@ -10,6 +10,7 @@ import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
+import EmojiIcon from "~/components/Icons/EmojiIcon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { replaceTitleVariables } from "~/utils/date";
@@ -43,7 +44,9 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {