Move template management to settings (#5811)

This commit is contained in:
Tom Moor
2023-09-10 15:46:12 -04:00
committed by GitHub
parent ac068c0c07
commit 0856f5f6ae
32 changed files with 432 additions and 267 deletions

View File

@@ -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: <NewDocumentIcon />,
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: <NewDocumentIcon />,
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: <LightBulbIcon />,
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) {

View File

@@ -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: <ShapesIcon />,
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: <SettingsIcon />,
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: <ShapesIcon />,
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,

View File

@@ -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: <ShapesIcon />,
title: t("Templates"),
to: templatesPath(),
to: settingsPath("templates"),
};
}

View File

@@ -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 (
<CompositeItem
@@ -84,7 +77,7 @@ function DocumentListItem(
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: document.url,
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
},
@@ -142,25 +135,6 @@ function DocumentListItem(
/>
</Content>
<Actions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
<>
<Button
as={Link}
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}

View File

@@ -1,4 +1,4 @@
import { LocationDescriptor } from "history";
import { LocationDescriptor, LocationDescriptorObject } from "history";
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
@@ -9,10 +9,20 @@ type Props = React.ComponentProps<typeof NavLink> & {
[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 }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
? children(rest.isActive ? rest.isActive(match, location) : match)
? children(
rest.isActive ? rest.isActive(match, location) : match,
location
)
: null}
</NavLink>
)}

View File

@@ -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() {
<Section>
{can.createDocument && (
<>
<SidebarLink
to={templatesPath()}
icon={<ShapesIcon />}
exact={false}
label={t("Templates")}
active={
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: undefined
}
/>
<ArchiveLink />
<TrashLink />
</>

View File

@@ -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() {
<SidebarLink
key={item.path}
to={item.path}
active={
item.path !== settingsPath()
? location.pathname.startsWith(item.path)
: undefined
}
icon={<item.icon />}
label={item.name}
/>

View File

@@ -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<React.ComponentProps<typeof NavLink>, "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<Props> = ({ children, ...rest }: Props) => {
const Tab: React.FC<Props> = ({
children,
exact,
exactQueryString,
...rest
}: Props) => {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<TabLink
{...rest}
exact={exact || exactQueryString}
activeStyle={activeStyle}
>
{(match, location) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
{match &&
(!exactQueryString ||
isEqual(
queryString.parse(location.search ?? ""),
queryString.parse(rest.to.search as string)
)) && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>

View File

@@ -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) {

View File

@@ -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<any>;
component: React.ComponentType<any>;
icon: React.FC<ComponentProps<typeof Icon>>;
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,

View File

@@ -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: <EditIcon />,
},
{
type: "route",
title: t("New nested document"),
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,
icon: <NewDocumentIcon />,
},
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",
},

View File

@@ -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: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});

View File

@@ -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) {
<MenuItem
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={<DocumentIcon />}
icon={
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
}
{...menu}
>
<TemplateItem>

View File

@@ -1,13 +1,16 @@
import { addDays, differenceInDays } from "date-fns";
import { t } from "i18next";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { ExportContentType } from "@shared/types";
import type { NavigationNode } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
@@ -122,6 +125,9 @@ export default class Document extends ParanoidModel {
@observable
archivedAt: string;
/**
* @deprecated Use path instead
*/
@observable
url: string;
@@ -153,9 +159,21 @@ export default class Document extends ParanoidModel {
return isRTL(this.title);
}
@computed
get path(): string {
const prefix = this.template ? settingsPath("templates") : "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `${prefix}/${slugifiedTitle}-${this.urlId}`;
}
@computed
get noun(): string {
return this.template ? "template" : "document";
return this.template ? t("template") : t("document");
}
@computed

View File

@@ -10,25 +10,34 @@ import Route from "~/components/ProfiledRoute";
import WebsocketProvider from "~/components/WebsocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
import lazy from "~/utils/lazyWithRetry";
import {
archivePath,
draftsPath,
homePath,
searchPath,
settingsPath,
matchDocumentSlug as slug,
trashPath,
} from "~/utils/routeHelpers";
const SettingsRoutes = lazyWithRetry(() => import("./settings"));
const Archive = lazyWithRetry(() => import("~/scenes/Archive"));
const Collection = lazyWithRetry(() => import("~/scenes/Collection"));
const Document = lazyWithRetry(() => import("~/scenes/Document"));
const Drafts = lazyWithRetry(() => import("~/scenes/Drafts"));
const Home = lazyWithRetry(() => import("~/scenes/Home"));
const Templates = lazyWithRetry(() => import("~/scenes/Templates"));
const Search = lazyWithRetry(() => import("~/scenes/Search"));
const Trash = lazyWithRetry(() => import("~/scenes/Trash"));
const SettingsRoutes = lazy(() => import("./settings"));
const Archive = lazy(() => import("~/scenes/Archive"));
const Collection = lazy(() => import("~/scenes/Collection"));
const Document = lazy(() => import("~/scenes/Document"));
const Drafts = lazy(() => import("~/scenes/Drafts"));
const Home = lazy(() => import("~/scenes/Home"));
const Search = lazy(() => import("~/scenes/Search"));
const Trash = lazy(() => import("~/scenes/Trash"));
const RedirectDocument = ({
match,
}: RouteComponentProps<{ documentSlug: string }>) => (
<Redirect
to={
match.params.documentSlug ? `/doc/${match.params.documentSlug}` : "/home"
match.params.documentSlug
? `/doc/${match.params.documentSlug}`
: homePath()
}
/>
);
@@ -49,24 +58,18 @@ function AuthenticatedRoutes() {
>
<Switch>
{can.createDocument && (
<Route exact path="/templates" component={Templates} />
<Route exact path={draftsPath()} component={Drafts} />
)}
{can.createDocument && (
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path={archivePath()} component={Archive} />
)}
{can.createDocument && (
<Route exact path="/drafts" component={Drafts} />
<Route exact path={trashPath()} component={Trash} />
)}
{can.createDocument && (
<Route exact path="/archive" component={Archive} />
)}
{can.createDocument && (
<Route exact path="/trash" component={Trash} />
)}
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Redirect exact from="/starred" to="/home" />
<Route path={`${homePath()}/:tab?`} component={Home} />
<Redirect from="/dashboard" to={homePath()} />
<Redirect exact from="/starred" to={homePath()} />
<Redirect exact from="/templates" to={settingsPath("templates")} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
@@ -81,8 +84,7 @@ function AuthenticatedRoutes() {
<Route exact path={`/doc/${slug}/insights`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route exact path={`${searchPath()}/:term?`} component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />

View File

@@ -1,8 +1,13 @@
import * as React from "react";
import { Switch } from "react-router-dom";
import { RouteComponentProps, Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
const Document = lazy(() => import("~/scenes/Document"));
export default function SettingsRoutes() {
const configs = useSettingsConfig();
@@ -17,6 +22,18 @@ export default function SettingsRoutes() {
component={config.component}
/>
))}
<Route
exact
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
component={Document}
/>
<Route
exact
path={`${settingsPath("templates")}/new`}
component={(props: RouteComponentProps) => (
<DocumentNew {...props} template />
)}
/>
<Route component={Error404} />
</Switch>
);

View File

@@ -29,7 +29,7 @@ const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker"));
type Props = {
/** ID of the associated document */
documentId: string;
/** Document to display */
/** Title to display */
title: string;
/** Emoji to display */
emoji?: string | null;
@@ -247,7 +247,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
value={title}
$emojiPickerIsOpen={emojiPickerIsOpen}
$containsEmoji={!!emoji}
autoFocus={!document.title}
autoFocus={!title}
maxLength={DocumentValidation.maxTitleLength}
readOnly={readOnly}
dir="auto"

View File

@@ -26,6 +26,7 @@ import EmojiIcon from "~/components/Icons/EmojiIcon";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
@@ -36,7 +37,7 @@ import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import TemplatesMenu from "~/menus/TemplatesMenu";
import { metaDisplay } from "~/utils/keyboard";
import { newDocumentPath, documentEditPath } from "~/utils/routeHelpers";
import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
@@ -243,31 +244,43 @@ function DocumentHeader({
{!isEditing &&
!isDeleted &&
!isRevision &&
(!isMobile || !isTemplate) &&
!isTemplate &&
!isMobile &&
document.collectionId && (
<Action>
<ShareButton document={document} />
</Action>
)}
{isEditing && (
<>
<Action>
<Tooltip
tooltip={t("Save")}
shortcut={`${metaDisplay}+enter`}
delay={500}
placement="bottom"
<Action>
<Tooltip
tooltip={t("Save")}
shortcut={`${metaDisplay}+enter`}
delay={500}
placement="bottom"
>
<Button
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
>
<Button
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
>
{isDraft ? t("Save draft") : t("Done editing")}
</Button>
</Tooltip>
</Action>
</>
{isDraft ? t("Save draft") : t("Done editing")}
</Button>
</Tooltip>
</Action>
)}
{isTemplate && (
<Action>
<Button
context={context}
action={navigateToTemplateSettings}
disabled={savingIsDisabled}
neutral={isDraft}
hideIcon
>
{t("Done editing")}
</Button>
</Action>
)}
{can.update &&
!isEditing &&
@@ -296,23 +309,6 @@ function DocumentHeader({
/>
</Action>
)}
{can.update &&
!isEditing &&
isTemplate &&
!isDraft &&
!isRevision && (
<Action>
<Button
icon={<PlusIcon />}
as={Link}
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
>
{t("New from template")}
</Button>
</Action>
)}
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip

View File

@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import queryString from "query-string";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -7,23 +6,31 @@ import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import CenteredContent from "~/components/CenteredContent";
import Flex from "~/components/Flex";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import useCurrentUser from "~/hooks/useCurrentUser";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { documentEditPath } from "~/utils/routeHelpers";
import { documentEditPath, documentPath } from "~/utils/routeHelpers";
function DocumentNew() {
type Props = {
// If true, the document will be created as a template.
template?: boolean;
};
function DocumentNew({ template }: Props) {
const history = useHistory();
const location = useLocation();
const query = useQuery();
const user = useCurrentUser();
const match = useRouteMatch<{ id?: string }>();
const { t } = useTranslation();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const id = match.params.id || "";
const id = match.params.id || query.get("collectionId");
useEffect(() => {
async function createDocument() {
const params = queryString.parse(location.search);
const parentDocumentId = params.parentDocumentId?.toString();
const parentDocumentId = query.get("parentDocumentId") ?? undefined;
const parentDocument = parentDocumentId
? documents.get(parentDocumentId)
: undefined;
@@ -37,12 +44,17 @@ function DocumentNew() {
collectionId: collection?.id,
parentDocumentId,
fullWidth: parentDocument?.fullWidth,
templateId: params.templateId?.toString(),
template: params.template === "true" ? true : false,
templateId: query.get("templateId") ?? undefined,
template,
title: "",
text: "",
});
history.replace(documentEditPath(document), location.state);
history.replace(
template || !user.separateEditMode
? documentPath(document)
: documentEditPath(document),
location.state
);
} catch (err) {
showToast(t("Couldnt create the document, try again?"), {
type: "error",

View File

@@ -1,8 +1,8 @@
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { RouteComponentProps } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { Action } from "~/components/Actions";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
@@ -10,18 +10,18 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import Text from "~/components/Text";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import NewTemplateMenu from "~/menus/NewTemplateMenu";
import { settingsPath } from "~/utils/routeHelpers";
function Templates(props: RouteComponentProps<{ sort: string }>) {
function Templates() {
const { documents } = useStores();
const { t } = useTranslation();
const team = useCurrentTeam();
const param = useQuery();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const { sort } = props.match.params;
const can = usePolicy(team);
const sort = param.get("sort") || "recent";
return (
<Scene
@@ -34,26 +34,33 @@ function Templates(props: RouteComponentProps<{ sort: string }>) {
}
>
<Heading>{t("Templates")}</Heading>
<Text type="secondary">
<Trans>
You can create templates to help your team create consistent and
accurate documentation.
</Trans>
</Text>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to="/templates" exact>
<Tab to={settingsPath("templates")} exactQueryString>
{t("Recently updated")}
</Tab>
<Tab to="/templates/alphabetical" exact>
<Tab
to={{
pathname: settingsPath("templates"),
search: queryString.stringify({
sort: "alphabetical",
}),
}}
exactQueryString
>
{t("Alphabetical")}
</Tab>
</Tabs>
}
empty={
<Empty>
{t("There are no templates just yet.")}{" "}
{can.createDocument &&
t(
"You can create templates to help your team create consistent and accurate documentation."
)}
</Empty>
}
empty={<Empty>{t("There are no templates just yet.")}</Empty>}
fetch={fetchTemplates}
documents={sort === "alphabetical" ? templatesAlphabetical : templates}
showCollection

View File

@@ -11,10 +11,6 @@ export function draftsPath(): string {
return "/drafts";
}
export function templatesPath(): string {
return "/templates";
}
export function archivePath(): string {
return "/archive";
}
@@ -50,22 +46,22 @@ export function updateCollectionPath(
}
export function documentPath(doc: Document): string {
return doc.url;
return doc.path;
}
export function documentEditPath(doc: Document): string {
return `${doc.url}/edit`;
return `${documentPath(doc)}/edit`;
}
export function documentInsightsPath(doc: Document): string {
return `${doc.url}/insights`;
return `${documentPath(doc)}/insights`;
}
export function documentHistoryPath(
doc: Document,
revisionId?: string
): string {
let base = `${doc.url}/history`;
let base = `${documentPath(doc)}/history`;
if (revisionId) {
base += `/${revisionId}`;
}
@@ -84,12 +80,15 @@ export function updateDocumentPath(oldUrl: string, document: Document): string {
);
}
export function newTemplatePath(collectionId: string) {
return settingsPath("templates") + `/new?collectionId=${collectionId}`;
}
export function newDocumentPath(
collectionId?: string | null,
params: {
parentDocumentId?: string;
templateId?: string;
template?: boolean;
} = {}
): string {
return collectionId