mirror of
https://github.com/outline/outline.git
synced 2025-04-10 03:03:45 +00:00
Move template management to settings (#5811)
This commit is contained in:
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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 />
|
||||
</>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
},
|
||||
|
@ -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} />,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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("Couldn’t create the document, try again?"), {
|
||||
type: "error",
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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();
|
||||
|
@ -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";
|
||||
|
@ -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(
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
||||
|
@ -1,9 +0,0 @@
|
||||
import slug from "slug";
|
||||
|
||||
slug.defaults.mode = "rfc3986";
|
||||
|
||||
export default function slugify(text: string): string {
|
||||
return slug(text, {
|
||||
remove: /[.]/g,
|
||||
});
|
||||
}
|
@ -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",
|
||||
"Couldn’t publish the document, try again?": "Couldn’t 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
16
shared/utils/slugify.ts
Normal 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,
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user