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

View File

@ -6,6 +6,7 @@ type Props = {
id?: string;
urlId?: string;
title: string;
emoji?: string;
text?: string;
state?: Buffer;
publish?: boolean;
@ -28,6 +29,7 @@ type Props = {
export default async function documentCreator({
title = "",
text = "",
emoji,
state,
id,
urlId,
@ -81,6 +83,7 @@ export default async function documentCreator({
fullWidth,
publishedAt,
importId,
emoji: templateDocument ? templateDocument.emoji : emoji,
title: templateDocument
? DocumentHelper.replaceTemplateVariables(templateDocument.title, user)
: title,

View File

@ -1,5 +1,6 @@
import randomstring from "randomstring";
import { v4 as uuidv4 } from "uuid";
import slugify from "@shared/utils/slugify";
import {
buildUser,
buildGroup,
@ -7,7 +8,6 @@ import {
buildTeam,
buildDocument,
} from "@server/test/factories";
import slugify from "@server/utils/slugify";
import Collection from "./Collection";
import Document from "./Document";

View File

@ -27,9 +27,9 @@ import isUUID from "validator/lib/isUUID";
import type { CollectionSort } from "@shared/types";
import { CollectionPermission, NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionValidation } from "@shared/validations";
import slugify from "@server/utils/slugify";
import CollectionGroup from "./CollectionGroup";
import CollectionUser from "./CollectionUser";
import Document from "./Document";

View File

@ -1,3 +1,4 @@
import slugify from "@shared/utils/slugify";
import Document from "@server/models/Document";
import {
buildDocument,
@ -6,7 +7,6 @@ import {
buildTeam,
buildUser,
} from "@server/test/factories";
import slugify from "@server/utils/slugify";
beforeEach(() => {
jest.resetAllMocks();

View File

@ -34,9 +34,9 @@ import {
import isUUID from "validator/lib/isUUID";
import type { NavigationNode } from "@shared/types";
import getTasks from "@shared/utils/getTasks";
import slugify from "@shared/utils/slugify";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { DocumentValidation } from "@shared/validations";
import slugify from "@server/utils/slugify";
import Backlink from "./Backlink";
import Collection from "./Collection";
import FileOperation from "./FileOperation";

View File

@ -179,11 +179,27 @@ allow(User, "move", Document, (user, document) => {
return user.teamId === document.teamId;
});
allow(User, ["pin", "unpin"], Document, (user, document) => {
if (!document || document.isDraft) {
allow(User, "pin", Document, (user, document) => {
if (
!document ||
document.isDraft ||
!document.isActive ||
document.template
) {
return false;
}
if (document.template) {
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
);
if (cannot(user, "update", document.collection)) {
return false;
}
return user.teamId === document.teamId;
});
allow(User, "unpin", Document, (user, document) => {
if (!document || document.isDraft || document.template) {
return false;
}
invariant(
@ -197,10 +213,12 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
});
allow(User, ["subscribe", "unsubscribe"], Document, (user, document) => {
if (!document || !document.isActive || document.isDraft) {
return false;
}
if (document.template) {
if (
!document ||
!document.isActive ||
document.isDraft ||
document.template
) {
return false;
}
invariant(
@ -284,7 +302,12 @@ allow(User, "restore", Document, (user, document) => {
});
allow(User, "archive", Document, (user, document) => {
if (!document || !document.isActive || document.isDraft) {
if (
!document ||
!document.isActive ||
document.isDraft ||
document.template
) {
return false;
}
invariant(

View File

@ -9,6 +9,7 @@ import { Op, ScopeOptions, WhereOptions } from "sequelize";
import { TeamPreference } from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import { bytesToHumanReadable } from "@shared/utils/files";
import slugify from "@shared/utils/slugify";
import documentCreator from "@server/commands/documentCreator";
import documentImporter from "@server/commands/documentImporter";
import documentLoader from "@server/commands/documentLoader";
@ -55,7 +56,6 @@ import ZipHelper from "@server/utils/ZipHelper";
import { getFileFromRequest } from "@server/utils/koa";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { getTeamFromContext } from "@server/utils/passport";
import slugify from "@server/utils/slugify";
import { assertPresent } from "@server/validation";
import pagination from "../middlewares/pagination";
import * as T from "./schema";

View File

@ -1,6 +1,7 @@
import Router from "koa-router";
import { Op } from "sequelize";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import slugify from "@shared/utils/slugify";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
@ -9,7 +10,6 @@ import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { authorize } from "@server/policies";
import { presentRevision } from "@server/presenters";
import { APIContext } from "@server/types";
import slugify from "@server/utils/slugify";
import pagination from "../middlewares/pagination";
import * as T from "./schema";

View File

@ -1,9 +0,0 @@
import slug from "slug";
slug.defaults.mode = "rfc3986";
export default function slugify(text: string): string {
return slug(text, {
remove: /[.]/g,
});
}

View File

@ -18,11 +18,13 @@
"Development": "Development",
"Open document": "Open document",
"New document": "New document",
"New from template": "New from template",
"New nested document": "New nested document",
"Publish": "Publish",
"Document published": "Document published",
"Published {{ documentName }}": "Published {{ documentName }}",
"Publish document": "Publish document",
"Unpublish": "Unpublish",
"Document unpublished": "Document unpublished",
"Unpublished {{ documentName }}": "Unpublished {{ documentName }}",
"Subscribe": "Subscribe",
"Subscribed to document notifications": "Subscribed to document notifications",
"Unsubscribe": "Unsubscribe",
@ -61,10 +63,10 @@
"Insights": "Insights",
"Home": "Home",
"Drafts": "Drafts",
"Templates": "Templates",
"Trash": "Trash",
"Settings": "Settings",
"Profile": "Profile",
"Templates": "Templates",
"Notifications": "Notifications",
"Preferences": "Preferences",
"API documentation": "API documentation",
@ -139,7 +141,6 @@
"Only visible to you": "Only visible to you",
"Draft": "Draft",
"Template": "Template",
"New doc": "New doc",
"You updated": "You updated",
"{{ userName }} updated": "{{ userName }} updated",
"You deleted": "You deleted",
@ -230,9 +231,9 @@
"No results for {{query}}": "No results for {{query}}",
"Logo": "Logo",
"Move document": "Move document",
"New doc": "New doc",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Collections": "Collections",
"New nested document": "New nested document",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
"Empty": "Empty",
"Go back": "Go back",
@ -403,6 +404,8 @@
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate account": "Activate account",
"template": "template",
"document": "document",
"published": "published",
"edited": "edited",
"created the collection": "created the collection",
@ -508,7 +511,6 @@
"Archived": "Archived",
"Save draft": "Save draft",
"Done editing": "Done editing",
"New from template": "New from template",
"Restore version": "Restore version",
"No history yet": "No history yet",
"Stats": "Stats",
@ -580,6 +582,7 @@
"Document permanently deleted": "Document permanently deleted",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
"Select a location to publish": "Select a location to publish",
"Document published": "Document published",
"Couldnt publish the document, try again?": "Couldnt publish the document, try again?",
"Publish in <em>{{ location }}</em>": "Publish in <em>{{ location }}</em>",
"view and edit access": "view and edit access",
@ -873,6 +876,9 @@
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Alphabetical": "Alphabetical",
"There are no templates just yet.": "There are no templates just yet.",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
"A confirmation code has been sent to your email address, please enter the code below to permanantly destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy this workspace.",
"Confirmation code": "Confirmation code",
@ -881,9 +887,6 @@
"Workspace name": "Workspace name",
"Your are creating a new workspace using your current account — <em>{{email}}</em>": "Your are creating a new workspace using your current account — <em>{{email}}</em>",
"To create a workspace under another email please sign up from the homepage": "To create a workspace under another email please sign up from the homepage",
"Alphabetical": "Alphabetical",
"There are no templates just yet.": "There are no templates just yet.",
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Trash is empty at the moment.": "Trash is empty at the moment.",
"A confirmation code has been sent to your email address, please enter the code below to permanantly destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy your account.",
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.",

16
shared/utils/slugify.ts Normal file
View File

@ -0,0 +1,16 @@
import slug from "slug";
slug.defaults.mode = "rfc3986";
/**
* Convert a string to a slug that can be used in a URL in kebab-case format,
* and remove periods.
*
* @param text The text to convert
* @returns The slugified text
*/
export default function slugify(text: string): string {
return slug(text, {
remove: /[.]/g,
});
}