Refactor templates (#11027)

closes #8674
This commit is contained in:
Tom Moor
2026-02-20 18:53:00 -05:00
committed by GitHub
parent 52448714d9
commit 7be893f9a3
83 changed files with 3185 additions and 1126 deletions
+3 -9
View File
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -530,15 +530,9 @@ export const createTemplate = createInternalLinkAction({
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
return newTemplatePath(collection?.id);
},
});
+12 -92
View File
@@ -42,12 +42,11 @@ import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -87,6 +86,7 @@ import type {
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -138,18 +138,13 @@ export const editDocument = createInternalLinkAction({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const { auth, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
return !!can?.update && !!auth.user?.separateEditMode;
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -222,12 +217,7 @@ export const createDocumentFromTemplate = createInternalLinkAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
return false;
}
@@ -468,7 +458,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -1055,7 +1045,7 @@ export const createTemplateFromDocument = createAction({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
if (!document?.isActive) {
return false;
}
return !!(
@@ -1107,46 +1097,8 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -1184,8 +1136,7 @@ export const moveDocument = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
if (!document) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -1193,25 +1144,6 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1270,10 +1202,7 @@ export const restoreDocument = createAction({
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !!collection?.isActive && !!(can.restore || can.unarchive);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1310,10 +1239,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !collection?.isActive && !!(can.restore || can.unarchive);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1498,12 +1424,7 @@ export const openDocumentInsights = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
return !!activeDocumentId && can.listViews && !document?.isDeleted;
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1604,7 +1525,6 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+224
View File
@@ -0,0 +1,224 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
queueMicrotask(window.print);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+9
View File
@@ -24,6 +24,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
const { templates } = useStores();
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
void templates.fetchAll();
}, [templates]);
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
templates.alphabetical.map((template) =>
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
},
})
),
[documents.templatesAlphabetical]
[templates.alphabetical]
);
const newFromTemplate = useMemo(
+2 -9
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
@@ -0,0 +1,17 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -5,13 +5,13 @@ import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import Switch from "./Switch";
import Text from "./Text";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
/** The original document to duplicate */
@@ -37,13 +37,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
}, [policies, collectionTrees]);
const copy = async () => {
if (!selectedPath) {
@@ -80,34 +75,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -117,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || copying} onClick={copy}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
@@ -19,8 +19,8 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -38,9 +38,17 @@ type Props = {
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -141,7 +149,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -216,7 +225,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
nodes[node].children.length > 0 || showDocuments !== false;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -402,7 +411,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
autoFocus
/>
<ListContainer>
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import { Node as SearchResult } from "./DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
document: Document;
@@ -44,21 +42,8 @@ function DocumentMove({ document }: Props) {
: true
);
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async () => {
if (!selectedPath) {
@@ -92,7 +77,7 @@ function DocumentMove({ document }: Props) {
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
@@ -106,7 +91,7 @@ function DocumentMove({ document }: Props) {
) : (
t("Select a location to move")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || moving} onClick={move}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
@@ -115,23 +100,4 @@ function DocumentMove({ document }: Props) {
);
}
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
export default observer(DocumentMove);
@@ -0,0 +1,87 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
+3
View File
@@ -0,0 +1,3 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
+1 -6
View File
@@ -39,7 +39,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -75,7 +74,6 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -83,7 +81,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -162,9 +160,6 @@ function DocumentListItem(
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
+1 -2
View File
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const canShowProgressBar = isTasks;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
-1
View File
@@ -49,7 +49,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
+15 -2
View File
@@ -26,6 +26,7 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
import { transparentize } from "polished";
const HEADER_HEIGHT = 40;
@@ -336,7 +337,8 @@ const THead = styled.div<{ $topPos: number }>`
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
background: ${s("background")};
`;
@@ -350,12 +352,17 @@ const TR = styled.div<{ $columns: string }>`
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
overflow: hidden;
&:last-child {
border-bottom: 0;
}
&:hover ${NudeButton}[aria-haspopup="menu"] {
opacity: 1;
}
`;
const TH = styled.span`
@@ -401,11 +408,17 @@ const TD = styled.span`
${NudeButton}[aria-haspopup="menu"] {
vertical-align: middle;
opacity: 0;
transition: opacity 100ms ease-in-out;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
&[aria-expanded="true"] {
opacity: 1;
}
}
`;
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+103
View File
@@ -0,0 +1,103 @@
import { observer } from "mobx-react";
import { InputIcon, ShapesIcon } from "outline-icons";
import React, { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import type { ProsemirrorData } from "@shared/types";
import type Template from "~/models/Template";
import Editor from "~/scenes/Document/components/Editor";
import { DocumentContextProvider } from "~/components/DocumentContext";
import LoadingIndicator from "~/components/LoadingIndicator";
import Notice from "~/components/Notice";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
export const TemplateForm = observer(function TemplateForm_({
handleSubmit,
template,
}: {
handleSubmit: (template: Template) => void;
template: Template;
}) {
const { dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(template);
const dataRef = useRef(template.data);
const ref = useRef(null);
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
const readOnly = !can.update && !template.isNew;
const handleChangeTitle = (title: string) => {
template.title = title;
};
const handleChangeIcon = (icon: string, color: string) => {
template.icon = icon;
template.color = color;
};
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
dataRef.current = value(false);
template.data = dataRef.current;
};
const handleSave = (options: { autosave?: boolean }) => {
if (options.autosave) {
return;
}
handleSubmit(template);
};
const handleCancel = () => {
dialogs.closeAllModals();
};
if (!template) {
return null;
}
return (
<DocumentContextProvider>
<React.Suspense fallback={null}>
{isUploading && <LoadingIndicator />}
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
<Editor
id={template.id}
ref={ref}
isDraft={false}
document={template}
value={readOnly ? template.data : undefined}
defaultValue={template.data}
onFileUploadStart={handleStartUpload}
onFileUploadStop={handleStopUpload}
onChangeTitle={handleChangeTitle}
onChangeIcon={handleChangeIcon}
onSave={handleSave}
onCancel={handleCancel}
onChange={handleChange}
readOnly={readOnly}
canUpdate={can.update}
autoFocus={template.createdAt === template.updatedAt}
template
/>
</React.Suspense>
</DocumentContextProvider>
);
});
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+6 -5
View File
@@ -8,7 +8,6 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
@@ -18,7 +17,7 @@ type Props = {
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const { documents, templates } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
@@ -28,15 +27,17 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
const template = await templates.templatize({
id: documentId,
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
history.push(template.path);
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
}, [t, templates, documentId, history, collectionId, publish]);
return (
<ConfirmationDialog
+2 -6
View File
@@ -19,10 +19,8 @@ import {
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory,
pinDocument,
createDocumentFromTemplate,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
@@ -36,7 +34,7 @@ import {
} from "~/actions/definitions/documents";
import { ActiveDocumentSection } from "~/actions/sections";
import useMobile from "./useMobile";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import usePolicy from "./usePolicy";
import useCurrentUser from "./useCurrentUser";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
@@ -50,7 +48,7 @@ type Props = {
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
export function useDocumentMenuAction({
@@ -100,12 +98,10 @@ export function useDocumentMenuAction({
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
importDocument,
createNewDocument,
pinDocument,
createDocumentFromTemplate,
ActionSeparator,
openDocumentComments,
openDocumentHistory,
+14 -7
View File
@@ -3,7 +3,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { ActionSeparator, createAction, createActionGroup } from "~/actions";
import { DocumentsSection } from "~/actions/sections";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -15,7 +15,7 @@ type Props = {
/** The document to which the templates will be applied */
documentId: string;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
/**
@@ -34,12 +34,12 @@ export function useTemplateMenuActions({
onSelectTemplate,
}: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { documents, templates: templatesStore } = useStores();
const { t } = useTranslation();
const document = documents.get(documentId);
const templateToAction = useCallback(
(template: Document): Action =>
(template: Template): Action =>
createAction({
name: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
@@ -66,8 +66,8 @@ export function useTemplateMenuActions({
return [];
}
const templates = documents.templates.filter(
(template) => template.publishedAt
const templates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const collectionTemplatesActions = templates
@@ -82,6 +82,13 @@ export function useTemplateMenuActions({
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToAction);
if (
!collectionTemplatesActions.length &&
!workspaceTemplatesActions.length
) {
return [];
}
return [
...collectionTemplatesActions,
ActionSeparator,
@@ -90,5 +97,5 @@ export function useTemplateMenuActions({
actions: workspaceTemplatesActions,
}),
];
}, []);
}, [document?.collectionId, templateToAction, t]);
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react";
import { DuplicateIcon, EditIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { ActionSeparator, createAction } from "~/actions";
import {
copyTemplate,
deleteTemplate,
moveTemplate,
} from "~/actions/definitions/templates";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for template management operations.
*
* @param template - the template to build actions for, or null to skip.
* @param onEdit - optional callback to handle editing the template.
* @returns action with children for use in menus.
*/
export function useTemplateSettingsActions(
template: Template | null,
onEdit?: () => void
) {
const { t } = useTranslation();
const { templates } = useStores();
const can = usePolicy(template ?? ({} as Template));
const section = "Template";
const actions = React.useMemo(
() =>
!template
? []
: [
createAction({
name: `${t("Edit")}`,
visible: !!can.update && !!onEdit,
icon: <EditIcon />,
section,
perform: () => onEdit?.(),
}),
createAction({
name: t("Duplicate"),
visible: !!can.duplicate,
icon: <DuplicateIcon />,
section,
perform: () => templates.duplicate(template),
}),
moveTemplate,
ActionSeparator,
copyTemplate,
ActionSeparator,
deleteTemplate,
],
[can.update, can.duplicate, onEdit, t, template, templates]
);
return useMenuAction(actions);
}
+2 -1
View File
@@ -7,6 +7,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
@@ -33,7 +34,7 @@ type Props = {
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
+2 -1
View File
@@ -17,6 +17,7 @@ import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { newTemplatePath } from "~/utils/routeHelpers";
import { AvatarSize } from "~/components/Avatar";
function NewTemplateMenu() {
const { t } = useTranslation();
@@ -44,7 +45,7 @@ function NewTemplateMenu() {
createInternalLinkAction({
name: t("Save in workspace"),
section: DocumentSection,
icon: <TeamLogo model={team} />,
icon: <TeamLogo model={team} size={AvatarSize.Small} />,
visible: can.createTemplate,
to: newTemplatePath(),
}),
+32
View File
@@ -0,0 +1,32 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useTemplateSettingsActions } from "~/hooks/useTemplateSettingsActions";
type Props = {
template: Template;
onEdit?: () => void;
};
function TemplateMenu({ template, onEdit }: Props) {
const { t } = useTranslation();
const rootAction = useTemplateSettingsActions(template, onEdit);
return (
<ActionContextProvider value={{ activeModels: [template] }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Template options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
}
export default observer(TemplateMenu);
+2 -1
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { useMenuAction } from "~/hooks/useMenuAction";
@@ -13,7 +14,7 @@ type Props = {
/** Whether to render the button as a compact icon */
isCompact?: boolean;
/** Callback to handle when a template is selected */
onSelectTemplate: (template: Document) => void;
onSelectTemplate: (template: Template) => void;
};
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
+3 -30
View File
@@ -21,7 +21,6 @@ import type DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import type Notification from "./Notification";
import type View from "./View";
@@ -150,12 +149,6 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
color?: string | null;
/**
* Whether this is a template.
*/
@observable
template: boolean;
/**
* Whether the document layout is displayed full page width.
*/
@@ -280,8 +273,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get path(): string {
const prefix =
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
const prefix = "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
@@ -293,7 +285,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get noun(): string {
return this.template ? t("template") : t("document");
return t("document");
}
@computed
@@ -392,11 +384,6 @@ export default class Document extends ArchivableModel implements Searchable {
return !!this.deletedAt;
}
@computed
get isTemplate(): boolean {
return !!this.template;
}
@computed
get isDraft(): boolean {
return !this.publishedAt;
@@ -462,11 +449,6 @@ export default class Document extends ArchivableModel implements Searchable {
return path.map((item) => item.asNavigationNode);
}
@computed
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@@ -580,15 +562,6 @@ export default class Document extends ArchivableModel implements Searchable {
this.lastViewedAt = view.lastViewedAt;
};
@action
templatize = ({
collectionId,
publish,
}: {
collectionId: string | null;
publish: boolean;
}) => this.store.templatize({ id: this.id, collectionId, publish });
@action
save = async (
fields?: Properties<typeof this>,
@@ -655,7 +628,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get isActive(): boolean {
return !this.isDeleted && !this.isTemplate && !this.isArchived;
return !this.isDeleted && !this.isArchived;
}
@computed
+155
View File
@@ -0,0 +1,155 @@
import { addDays } from "date-fns";
import i18n from "i18next";
import { computed, observable } from "mobx";
import type { ProsemirrorData } from "@shared/types";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
import type TemplatesStore from "~/stores/TemplatesStore";
import User from "~/models/User";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import type { Searchable } from "./interfaces/Searchable";
export default class Template extends ParanoidModel implements Searchable {
static modelName = "Template";
store: TemplatesStore;
@Field
@observable.shallow
data: ProsemirrorData;
@computed
get searchContent(): string {
return this.title;
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted;
}
/**
* The id of the collection that this template belongs to, if any.
*/
@Field
@observable
collectionId?: string | null;
/**
* The collection that this template belongs to.
*/
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/**
* The title of the template.
*/
@Field
@observable
title: string;
/**
* An icon (or) emoji to use as the template icon.
*/
@Field
@observable
icon?: string | null;
/**
* The color to use for the template icon.
*/
@Field
@observable
color?: string | null;
/**
* Whether the template layout is displayed full page width.
*/
@Field
@observable
fullWidth: boolean;
/**
* The likely language of the template, in ISO 639-1 format.
*/
@Field
@observable
language: string | undefined;
@Relation(() => User)
createdBy: User | undefined;
@Relation(() => User)
updatedBy: User | undefined;
@observable
urlId: string;
/**
* Returns the direction of the template text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
return this.rtl ? "rtl" : "ltr";
}
/**
* Returns true if the template text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
}
@computed
get path(): string {
if (!this.title) {
return `${settingsPath("templates")}/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `${settingsPath("templates")}/${slugifiedTitle}-${this.urlId}`;
}
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
@computed
get hasEmptyTitle(): boolean {
return this.title === "";
}
@computed
get isWorkspaceTemplate(): boolean {
return !this.collectionId;
}
@computed
get permanentlyDeletedAt(): string | undefined {
if (!this.deletedAt) {
return undefined;
}
return addDays(new Date(this.deletedAt), 30).toString();
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@computed
get initial(): string {
return (this.titleWithDefault?.charAt(0) ?? "?").toUpperCase();
}
@computed
get isActive(): boolean {
return !this.isDeleted;
}
}
+7 -3
View File
@@ -1,17 +1,21 @@
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import type { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type Document from "../Document";
import { Schema } from "prosemirror-model";
import { Node } from "prosemirror-model";
interface HasData {
data: ProsemirrorData;
}
export class ProsemirrorHelper {
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
static toMarkdown = (document: Document) => {
static toMarkdown = (document: HasData) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
@@ -35,7 +39,7 @@ export class ProsemirrorHelper {
*
* @returns The plain text representation of the document as a string.
*/
static toPlainText = (document: Document) => {
static toPlainText = (document: HasData) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
+8 -11
View File
@@ -1,15 +1,14 @@
import type { RouteComponentProps } from "react-router-dom";
import { Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Errors/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const Document = lazy(() => import("~/scenes/Document"));
const Template = lazy(() => import("~/scenes/Settings/Template"));
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
function SettingsRoutes() {
const configs = useSettingsConfig();
@@ -27,20 +26,18 @@ function SettingsRoutes() {
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={`${settingsPath("applications")}/:id`}
path={settingsPath("applications", ":id")}
component={Application}
/>
<Route
exact
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
component={Document}
path={settingsPath("templates", "new")}
component={TemplateNew}
/>
<Route
exact
path={`${settingsPath("templates")}/new`}
component={(props: RouteComponentProps) => (
<DocumentNew {...props} template />
)}
path={settingsPath("templates", ":id")}
component={Template}
/>
<Route component={Error404} />
</Switch>
@@ -167,7 +167,7 @@ function DataLoader({ match, children }: Props) {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
if (!missingPolicy && !can.update && isEditRoute) {
history.push(document.url);
return;
}
+7 -10
View File
@@ -23,9 +23,10 @@ import { TextHelper } from "@shared/utils/TextHelper";
import { determineIconType } from "@shared/utils/icon";
import { isModKey } from "@shared/utils/keyboard";
import type RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import type Document from "~/models/Document";
import Template from "~/models/Template";
import type Revision from "~/models/Revision";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
import DocumentPublish from "~/scenes/DocumentPublish";
import ErrorBoundary from "~/components/ErrorBoundary";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -140,7 +141,7 @@ class DocumentScene extends React.Component<Props> {
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (template: Document | Revision, selection?: Selection) => {
replaceSelection = (template: Template | Revision, selection?: Selection) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -163,7 +164,7 @@ class DocumentScene extends React.Component<Props> {
this.isEditorDirty = true;
if (template instanceof Document) {
if (template instanceof Template) {
this.props.document.templateId = template.id;
this.props.document.fullWidth = template.fullWidth;
}
@@ -417,7 +418,7 @@ class DocumentScene extends React.Component<Props> {
void this.onSave();
});
handleSelectTemplate = async (template: Document | Revision) => {
handleSelectTemplate = async (template: Template | Revision) => {
const editorRef = this.editor.current;
if (!editorRef) {
return;
@@ -466,10 +467,7 @@ class DocumentScene extends React.Component<Props> {
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left);
const showContents =
tocPos &&
(isShare
? ui.tocVisible !== false
: !document.isTemplate && ui.tocVisible === true);
tocPos && (isShare ? ui.tocVisible !== false : ui.tocVisible === true);
const tocOffset =
tocPos === TOCPosition.Left
? EditorStyleHelper.tocWidth / -2
@@ -597,7 +595,6 @@ class DocumentScene extends React.Component<Props> {
ref={this.editor}
multiplayer={multiplayerEditor}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.data : undefined}
defaultValue={document.data}
@@ -8,6 +8,7 @@ import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import type Template from "~/models/Template";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
@@ -21,7 +22,7 @@ import NudeButton from "~/components/NudeButton";
type Props = {
/* The document to display meta data for */
document: Document;
document: Document | Template;
revision?: Revision;
to?: LocationDescriptor;
rtl?: boolean;
@@ -44,13 +45,19 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const commentingEnabled = !!team.getPreference(TeamPreference.Commenting);
return (
<Meta document={document} revision={revision} to={to} replace {...rest}>
<Meta
document={document as Document}
revision={revision}
to={to}
replace
{...rest}
>
{commentingEnabled && can.comment && (
<>
<Separator />
<CommentLink
to={{
pathname: documentPath(document),
pathname: documentPath(document as Document),
state: { sidebarContext },
}}
onClick={() => ui.toggleComments()}
@@ -62,10 +69,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
</CommentLink>
</>
)}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
{totalViewers && can.listViews && !(document as Document).isDraft ? (
<Wrapper>
<Separator />
<InsightsButton action={openDocumentInsights}>
+8 -7
View File
@@ -10,6 +10,7 @@ import { TeamPreference } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Comment from "~/models/Comment";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import type { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import type { Props as EditorProps } from "~/components/Editor";
@@ -43,7 +44,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;
onChangeIcon: (icon: string | null, color: string | null) => void;
id: string;
document: Document;
document: Document | Template;
isDraft: boolean;
multiplayer?: boolean;
onSave: (options: {
@@ -51,7 +52,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
autosave?: boolean;
publish?: boolean;
}) => void;
children: React.ReactNode;
children?: React.ReactNode;
};
/**
@@ -213,23 +214,23 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
{t("Last updated")} <Time dateTime={document.updatedAt} addSuffix />
</SharedMeta>
) : null
) : (
) : !rest.template ? (
<DocumentMeta
document={document}
document={document as Document}
to={
shareId
? undefined
: {
pathname:
match.path === matchDocumentHistory
? documentPath(document)
: documentHistoryPath(document),
? documentPath(document as Document)
: documentHistoryPath(document as Document),
state: { sidebarContext },
}
}
rtl={direction === "rtl"}
/>
)}
) : null}
<EditorComponent
ref={mergeRefs([ref, handleRefChanged])}
lang={getLangFor(document.language)}
+23 -31
View File
@@ -9,6 +9,7 @@ import useMeasure from "react-use-measure";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import type Template from "~/models/Template";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
@@ -20,7 +21,6 @@ import Header from "~/components/Header";
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 useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -53,7 +53,7 @@ type Props = {
isPublishing: boolean;
publishingIsDisabled: boolean;
savingIsDisabled: boolean;
onSelectTemplate: (template: Document) => void;
onSelectTemplate: (template: Template) => void;
onSave: (options: {
done?: boolean;
publish?: boolean;
@@ -116,12 +116,10 @@ function DocumentHeader({
}, [ui, isShare]);
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const { isDeleted } = document;
const canToggleEmbeds = team?.documentEmbeds;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
@@ -229,12 +227,12 @@ function DocumentHeader({
isMobile ? (
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
<DocumentBreadcrumb document={document as Document}>
{toc}{" "}
<Star
document={document as Document}
color={theme.textSecondary}
/>
</DocumentBreadcrumb>
)
}
@@ -260,24 +258,21 @@ function DocumentHeader({
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) &&
!isTemplate &&
isNew &&
can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && !isTemplate && can.update && (
{(isEditing || !user?.separateEditMode) && isNew && can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document as Document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplateEditable) && (
{isEditing && (
<Action>
<Tooltip
content={isDraft ? t("Save draft") : t("Done editing")}
@@ -285,8 +280,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
action={isTemplate ? navigateToTemplateSettings : undefined}
onClick={isTemplate ? undefined : handleSave}
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
hideIcon
@@ -340,9 +334,7 @@ function DocumentHeader({
hideOnActionDisabled
hideIcon
>
{document.collectionId || document.isWorkspaceTemplate
? t("Publish")
: `${t("Publish")}`}
{t("Publish")}
</Button>
</Action>
)}
+3 -28
View File
@@ -1,7 +1,6 @@
import { differenceInDays } from "date-fns";
import { TrashIcon, ArchiveIcon, ShapesIcon, InputIcon } from "outline-icons";
import { TrashIcon, ArchiveIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import type Document from "~/models/Document";
import ErrorBoundary from "~/components/ErrorBoundary";
import Notice from "~/components/Notice";
@@ -25,7 +24,7 @@ function Days(props: { dateTime: string }) {
);
}
export default function Notices({ document, readOnly }: Props) {
export default function Notices({ document }: Props) {
const { t } = useTranslation();
function permanentlyDeletedDescription() {
@@ -41,12 +40,7 @@ export default function Notices({ document, readOnly }: Props) {
? new Date().toISOString()
: document.permanentlyDeletedAt;
return document.template ? (
<Trans>
This template will be permanently deleted in{" "}
<Days dateTime={permanentlyDeletedAt} /> unless restored.
</Trans>
) : (
return (
<Trans>
This document will be permanently deleted in{" "}
<Days dateTime={permanentlyDeletedAt} /> unless restored.
@@ -56,19 +50,6 @@ export default function Notices({ document, readOnly }: Props) {
return (
<ErrorBoundary>
{document.isTemplate && !readOnly && (
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
)}
{document.archivedAt && !document.deletedAt && (
<Notice icon={<ArchiveIcon />}>
{t("Archived by {{userName}}", {
@@ -93,9 +74,3 @@ export default function Notices({ document, readOnly }: Props) {
</ErrorBoundary>
);
}
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
+4 -26
View File
@@ -8,12 +8,7 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import {
collectionPath,
documentPath,
homePath,
settingsPath,
} from "~/utils/routeHelpers";
import { collectionPath, documentPath, homePath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -27,8 +22,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const canArchive = !document.isDraft && !document.isArchived;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -64,13 +58,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
}
}
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collection
? collectionPath(collection)
: homePath();
const path = collection ? collectionPath(collection) : homePath();
history.push(path);
}
@@ -104,17 +92,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{document.isTemplate ? (
<Trans
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
values={{
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : nestedDocumentsCount < 1 ? (
{nestedDocumentsCount < 1 ? (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
+2 -8
View File
@@ -13,12 +13,7 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { documentEditPath, documentPath } from "~/utils/routeHelpers";
type Props = {
// If true, the document will be created as a template.
template?: boolean;
};
function DocumentNew({ template }: Props) {
function DocumentNew() {
const history = useHistory();
const location = useLocation();
const query = useQuery();
@@ -51,7 +46,6 @@ function DocumentNew({ template }: Props) {
parentDocument?.fullWidth ||
user.getPreference(UserPreference.FullWidthDocuments),
templateId: query.get("templateId") ?? undefined,
template,
title: query.get("title") ?? "",
data: ProsemirrorHelper.getEmptyDocument(),
},
@@ -72,7 +66,7 @@ function DocumentNew({ template }: Props) {
}
history.replace(
template || !user.separateEditMode
!user.separateEditMode
? documentPath(document)
: documentEditPath(document),
location.state
-1
View File
@@ -354,7 +354,6 @@ function Search() {
highlight={query}
context={result.context}
showCollection
showTemplate
/>
))
: null
+125
View File
@@ -0,0 +1,125 @@
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useEffect, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { Action } from "~/components/Actions";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scene from "~/components/Scene";
import { TemplateForm } from "~/components/Template/TemplateForm";
import { createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import TemplateMenu from "~/menus/TemplateMenu";
import { collectionPath, settingsPath } from "~/utils/routeHelpers";
import type Template from "~/models/Template";
import history from "~/utils/history";
type Props = {
template: Template;
};
const LoadingState = observer(function LoadingState() {
const { id } = useParams<{ id: string }>();
const { templates, ui } = useStores();
const template = templates.get(id);
const { request } = useRequest(() => templates.fetch(id));
useEffect(() => {
if (!template) {
void request();
}
}, [template, request]);
useEffect(() => {
if (template) {
ui.addActiveModel(template);
}
return () => {
template && ui.removeActiveModel(template);
};
}, [template, ui]);
if (!template) {
return <LoadingIndicator />;
}
return <TemplateSetting template={template} />;
});
const TemplateSetting = observer(function Template_({ template }: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const [saving, setSaving] = useState(false);
const collection = template.collectionId
? collections.get(template.collectionId)
: undefined;
const breadcrumbActions = useMemo(
() => [
createInternalLinkAction({
name: t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
to: settingsPath("templates"),
}),
...(collection
? [
createInternalLinkAction({
name: collection.name,
section: NavigationSection,
icon: <CollectionIcon collection={collection} />,
to: collectionPath(collection),
}),
]
: []),
],
[t, collection]
);
const handleSubmit = useCallback(async () => {
if (!template.data || ProsemirrorHelper.isEmptyData(template.data)) {
toast.message(t("A template must have content"));
return;
}
setSaving(true);
try {
await template.save();
history.push(settingsPath("templates"));
} catch (error) {
toast.error(error.message);
} finally {
setSaving(false);
}
}, [template, t]);
return (
<Scene
title={template.title}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<>
<Action>
<Button onClick={handleSubmit} disabled={saving}>
{t("Save")}
</Button>
</Action>
<Action>
<TemplateMenu template={template} />
</Action>
</>
}
>
<TemplateForm template={template} handleSubmit={handleSubmit} />
</Scene>
);
});
export default LoadingState;
+89
View File
@@ -0,0 +1,89 @@
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Template from "~/models/Template";
import { Action } from "~/components/Actions";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Scene from "~/components/Scene";
import { TemplateForm } from "~/components/Template/TemplateForm";
import { createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { collectionPath, settingsPath } from "~/utils/routeHelpers";
import history from "~/utils/history";
function TemplateNewScene() {
const { t } = useTranslation();
const { templates, collections } = useStores();
const params = useQuery();
const collectionId = params.get("collectionId") || undefined;
const collection = collectionId ? collections.get(collectionId) : undefined;
const [template] = useState(
() => new Template({ title: "", collectionId }, templates)
);
const [saving, setSaving] = useState(false);
const breadcrumbActions = useMemo(
() => [
createInternalLinkAction({
name: t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
to: settingsPath("templates"),
}),
...(collection
? [
createInternalLinkAction({
name: collection.name,
section: NavigationSection,
icon: <CollectionIcon collection={collection} />,
to: collectionPath(collection),
}),
]
: []),
],
[t, collection]
);
const handleSubmit = useCallback(async () => {
if (!template.data || ProsemirrorHelper.isEmptyData(template.data)) {
toast.message(t("A template must have content"));
return;
}
setSaving(true);
try {
await template.save();
history.push(settingsPath("templates"));
} catch (error) {
toast.error(error.message);
} finally {
setSaving(false);
}
}, [template, t]);
return (
<Scene
title={t("New template")}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<Action>
<Button onClick={handleSubmit} disabled={saving}>
{t("Save")}
</Button>
</Action>
}
>
<TemplateForm template={template} handleSubmit={handleSubmit} />
</Scene>
);
}
export default observer(TemplateNewScene);
+124 -45
View File
@@ -1,75 +1,154 @@
import type { ColumnSort } from "@tanstack/react-table";
import deburr from "lodash/deburr";
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import queryString from "query-string";
import { useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useEffect, useMemo, useCallback, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import type Template from "~/models/Template";
import { Action } from "~/components/Actions";
import Empty from "~/components/Empty";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import Text from "~/components/Text";
import NewTemplateMenu from "~/menus/NewTemplateMenu";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import NewTemplateMenu from "~/menus/NewTemplateMenu";
import { settingsPath } from "~/utils/routeHelpers";
import { useTableRequest } from "~/hooks/useTableRequest";
import { StickyFilters } from "./components/StickyFilters";
import { TemplatesTable } from "./components/TemplatesTable";
function getFilteredTemplates(templates: Template[], query?: string) {
if (!query?.length) {
return templates;
}
const normalizedQuery = deburr(query.toLocaleLowerCase());
return templates.filter((template) =>
deburr(template.title).toLocaleLowerCase().includes(normalizedQuery)
);
}
function Templates() {
const { documents } = useStores();
const { t } = useTranslation();
const param = useQuery();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const sort = param.get("sort") || "recent";
const { templates } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const history = useHistory();
const location = useLocation();
const params = useQuery();
const [query, setQuery] = useState("");
const reqParams = useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: getFilteredTemplates(templates.all, reqParams.query),
sort,
reqFn: templates.fetchPage,
reqParams,
});
const isEmpty = !loading && !templates.all.length;
const updateQuery = useCallback(
(value: string) => {
if (value) {
params.set("query", value);
} else {
params.delete("query");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
useEffect(() => {
void documents.fetchDrafts();
}, [documents]);
if (error) {
toast.error(t("Could not load templates"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateQuery(query), 250);
return () => clearTimeout(timeout);
}, [query, updateQuery]);
return (
<Scene
icon={<ShapesIcon />}
title={t("Templates")}
icon={<ShapesIcon />}
actions={
<Action>
<NewTemplateMenu />
</Action>
<>
{can.createTemplate && (
<Action>
<NewTemplateMenu />
</Action>
)}
</>
}
wide
>
<Heading>{t("Templates")}</Heading>
<Text as="p" type="secondary">
<Trans>
You can create templates to help your team create consistent and
accurate documentation.
Templates help your team create consistent and accurate documentation.
</Trans>
</Text>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to={settingsPath("templates")} exactQueryString>
{t("Recently updated")}
</Tab>
<Tab
to={{
pathname: settingsPath("templates"),
search: queryString.stringify({
sort: "alphabetical",
}),
{isEmpty ? (
<Empty>{t("No templates have been created yet")}</Empty>
) : (
<>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<TemplatesTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
exactQueryString
>
{t("Alphabetical")}
</Tab>
</Tabs>
}
empty={<Empty>{t("There are no templates just yet.")}</Empty>}
fetch={fetchTemplates}
documents={sort === "alphabetical" ? templatesAlphabetical : templates}
showCollection
showDraft
/>
/>
</ConditionalFade>
</>
)}
</Scene>
);
}
@@ -21,7 +21,7 @@ import { s } from "@shared/styles";
import styled from "styled-components";
import { HStack } from "~/components/primitives/HStack";
const ROW_HEIGHT = 60;
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
@@ -22,7 +22,7 @@ import { FILTER_HEIGHT } from "./StickyFilters";
import { HStack } from "~/components/primitives/HStack";
import { VStack } from "~/components/primitives/VStack";
const ROW_HEIGHT = 60;
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
@@ -0,0 +1,180 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Flex from "@shared/components/Flex";
import Icon from "@shared/components/Icon";
import { hover } from "@shared/styles";
import type Template from "~/models/Template";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ButtonLink from "~/components/ButtonLink";
import { HEADER_HEIGHT } from "~/components/Header";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Text from "~/components/Text";
import Time from "~/components/Time";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useTemplateSettingsActions } from "~/hooks/useTemplateSettingsActions";
import TemplateMenu from "~/menus/TemplateMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
import history from "~/utils/history";
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Template>, "columns" | "rowHeight">;
const TemplateRowContextMenu = observer(function TemplateRowContextMenu({
template,
menuLabel,
children,
}: {
template: Template;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useTemplateSettingsActions(template, () =>
history.push(template.path)
);
return (
<ActionContextProvider value={{ activeModels: [template] }}>
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
</ActionContextProvider>
);
});
export function TemplatesTable(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const handleOpen = (template: Template) => () => {
history.push(template.path);
};
const applyContextMenu = useCallback(
(template: Template, rowElement: React.ReactNode) => (
<TemplateRowContextMenu
template={template}
menuLabel={t("Template options")}
>
{rowElement}
</TemplateRowContextMenu>
),
[t]
);
const columns = React.useMemo<TableColumn<Template>[]>(
() =>
compact<TableColumn<Template>>([
{
type: "data",
id: "title",
header: t("Title"),
accessor: (template) => template.titleWithDefault,
component: (template) => (
<ButtonLink onClick={handleOpen(template)}>
<Flex align="center" gap={4}>
{template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color || undefined}
size={24}
/>
) : (
<DocumentIcon size={24} color={theme.textSecondary} />
)}
<Title>{template.titleWithDefault}</Title>
</Flex>
</ButtonLink>
),
width: "4fr",
},
{
type: "data",
id: "collectionId",
header: t("Visibility"),
accessor: (template) => template.collection?.name,
component: (template) => <Permission template={template} />,
width: "2fr",
},
{
type: "data",
id: "lastModifiedById",
header: t("Updated by"),
accessor: (template) => template.updatedBy?.name,
sortable: false,
component: (template) => (
<Flex align="center" gap={8}>
<Avatar model={template.updatedBy} size={AvatarSize.Small} />{" "}
{template.updatedBy?.name}{" "}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
header: t("Date created"),
accessor: (title) => title.createdAt,
component: (title) =>
title.createdAt ? (
<Time dateTime={title.createdAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "action",
id: "action",
component: (template) => (
<TemplateMenu template={template} onEdit={handleOpen(template)} />
),
width: "50px",
},
]),
[t]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={applyContextMenu}
{...props}
/>
);
}
const Permission = observer(({ template }: { template: Template }) => {
const { t } = useTranslation();
React.useEffect(() => {
void template?.loadRelations();
}, [template]);
return (
<Flex align="center" gap={4}>
{template.collection ? (
<CollectionIcon collection={template.collection} />
) : null}
{template.collectionId ? template.collection?.name : t("Workspace")}
</Flex>
);
});
const Title = styled(Text)`
&: ${hover} {
text-decoration: underline;
cursor: var(--pointer);
}
`;
+1 -72
View File
@@ -80,10 +80,7 @@ export default class DocumentsStore extends Store<Document> {
@computed
get all(): Document[] {
return filter(
this.orderedData,
(d) => !d.archivedAt && !d.deletedAt && !d.template
);
return filter(this.orderedData, (d) => !d.archivedAt && !d.deletedAt);
}
@computed
@@ -105,18 +102,6 @@ export default class DocumentsStore extends Store<Document> {
return orderBy(this.all, "popularityScore", "desc");
}
@computed
get templates(): Document[] {
return orderBy(
filter(
this.orderedData,
(d) => !d.archivedAt && !d.deletedAt && d.template
),
"updatedAt",
"desc"
);
}
createdByUser(userId: string): Document[] {
return orderBy(
filter(this.all, (d) => d.createdBy?.id === userId),
@@ -159,21 +144,6 @@ export default class DocumentsStore extends Store<Document> {
);
}
templatesInCollection(collectionId: string): Document[] {
return orderBy(
filter(
this.orderedData,
(d) =>
!d.archivedAt &&
!d.deletedAt &&
d.template === true &&
d.collectionId === collectionId
),
"updatedAt",
"desc"
);
}
publishedInCollection(collectionId: string): Document[] {
return filter(
this.all,
@@ -242,11 +212,6 @@ export default class DocumentsStore extends Store<Document> {
);
}
@computed
get templatesAlphabetical(): Document[] {
return naturalSort(this.templates, "title");
}
@computed
get totalDrafts(): number {
return this.drafts().length;
@@ -358,14 +323,6 @@ export default class DocumentsStore extends Store<Document> {
options?: PaginationParams
): Promise<Document[]> => this.fetchNamedPage("list", options);
@action
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("list", { ...options, template: true });
@action
fetchAllTemplates = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchAll({ ...options, template: true });
@action
fetchAlphabetical = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("list", {
@@ -494,34 +451,6 @@ export default class DocumentsStore extends Store<Document> {
return;
};
@action
templatize = async ({
id,
collectionId,
publish,
}: {
id: string;
collectionId: string | null;
publish: boolean;
}): Promise<Document | null | undefined> => {
const doc: Document | null | undefined = this.data.get(id);
invariant(doc, "Document should exist");
if (doc.template) {
return;
}
const res = await client.post("/documents.templatize", {
id,
collectionId,
publish,
});
invariant(res?.data, "Document not available");
this.addPolicies(res.policies);
this.add(res.data);
return this.data.get(res.data.id);
};
override fetch = (id: string, options: FetchOptions = {}) =>
super.fetch(
id,
+3
View File
@@ -28,6 +28,7 @@ import SearchesStore from "./SearchesStore";
import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import TemplatesStore from "./TemplatesStore";
import UiStore from "./UiStore";
import UnfurlsStore from "./UnfurlsStore";
import UserMembershipsStore from "./UserMembershipsStore";
@@ -65,6 +66,7 @@ export default class RootStore {
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
templates: TemplatesStore;
users: UsersStore;
views: ViewsStore;
fileOperations: FileOperationsStore;
@@ -96,6 +98,7 @@ export default class RootStore {
this.registerStore(SharesStore);
this.registerStore(StarsStore);
this.registerStore(SubscriptionsStore);
this.registerStore(TemplatesStore);
this.registerStore(UnfurlsStore);
this.registerStore(UsersStore);
this.registerStore(ViewsStore);
+82
View File
@@ -0,0 +1,82 @@
import orderBy from "lodash/orderBy";
import filter from "lodash/filter";
import { action, computed } from "mobx";
import { invariant } from "mobx-utils";
import naturalSort from "@shared/utils/naturalSort";
import Template from "~/models/Template";
import { client } from "~/utils/ApiClient";
import type RootStore from "./RootStore";
import Store from "./base/Store";
export default class TemplatesStore extends Store<Template> {
constructor(rootStore: RootStore) {
super(rootStore, Template);
}
@computed
get alphabetical(): Template[] {
return naturalSort(Array.from(this.data.values()), "title");
}
@computed
get all(): Template[] {
return filter(this.orderedData, (d) => !d.deletedAt);
}
@action
duplicate = async (
template: Template,
options?: {
title?: string;
publish?: boolean;
}
) => {
const res = await client.post("/templates.duplicate", {
id: template.id,
...options,
});
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
this.add(res.data);
};
@action
templatize = async ({
id,
collectionId,
publish,
}: {
id: string;
collectionId: string | null;
publish: boolean;
}): Promise<Template | undefined> => {
const res = await client.post("/documents.templatize", {
id,
collectionId,
publish,
});
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
this.add(res.data);
return this.data.get(res.data.id);
};
get(id: string): Template | undefined {
return id
? (this.data.get(id) ??
this.orderedData.find((doc) => id.endsWith(doc.urlId)))
: undefined;
}
@computed
get active(): Template | undefined {
return this.rootStore.ui.getActiveModels(Template)?.[0];
}
@computed
get orderedData(): Template[] {
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
}
}
+1 -1
View File
@@ -2,9 +2,9 @@ import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import Document from "~/models/Document";
import type Model from "~/models/base/Model";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
@@ -251,6 +251,10 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "oauthClients.delete":
// Ignored
return;
case "templates.create":
case "templates.update":
case "templates.delete":
case "templates.restore":
case "passkeys.create":
case "passkeys.update":
case "passkeys.delete":
+7 -33
View File
@@ -4,6 +4,7 @@ import {
buildCollection,
buildDocument,
buildFileOperation,
buildTemplate,
} from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import documentCreator from "./documentCreator";
@@ -183,7 +184,7 @@ describe("documentCreator", () => {
teamId: user.teamId,
});
const templateDocument = await buildDocument({
const template = await buildTemplate({
title: "Template Document",
text: "Template content",
icon: "📋",
@@ -197,7 +198,7 @@ describe("documentCreator", () => {
const document = await withAPIContext(user, (ctx) =>
documentCreator(ctx, {
title: "From Template",
templateDocument,
template,
collectionId: collection.id,
})
);
@@ -215,7 +216,7 @@ describe("documentCreator", () => {
teamId: user.teamId,
});
const templateDocument = await buildDocument({
const template = await buildTemplate({
title: "Template Title",
text: "Template content",
userId: user.id,
@@ -225,7 +226,7 @@ describe("documentCreator", () => {
const document = await withAPIContext(user, (ctx) =>
documentCreator(ctx, {
templateDocument,
template,
collectionId: collection.id,
})
);
@@ -240,7 +241,7 @@ describe("documentCreator", () => {
teamId: user.teamId,
});
const templateDocument = await buildDocument({
const template = await buildTemplate({
title: "Template Document",
text: "Template content",
userId: user.id,
@@ -251,7 +252,7 @@ describe("documentCreator", () => {
await expect(
withAPIContext(user, (ctx) =>
documentCreator(ctx, {
templateDocument,
template,
state: Buffer.from("some state"),
collectionId: collection.id,
})
@@ -260,33 +261,6 @@ describe("documentCreator", () => {
"State cannot be set when creating a document from a template"
);
});
it("should handle template flag correctly", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const templateDocument = await buildDocument({
title: "Template Document",
text: "Template content",
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const document = await withAPIContext(user, (ctx) =>
documentCreator(ctx, {
templateDocument,
template: true,
collectionId: collection.id,
})
);
expect(document.template).toBe(true);
expect(document.templateId).toBe(templateDocument.id);
});
});
describe("parent document handling", () => {
+14 -24
View File
@@ -1,7 +1,7 @@
import type { Optional } from "utility-types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { Document } from "@server/models";
import { Document, type Template } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import type { APIContext } from "@server/types";
@@ -20,7 +20,6 @@ type Props = Optional<
| "parentDocumentId"
| "importId"
| "apiImportId"
| "template"
| "fullWidth"
| "sourceMetadata"
| "editorVersion"
@@ -31,8 +30,8 @@ type Props = Optional<
> & {
state?: Buffer;
publish?: boolean;
template?: Template | null;
index?: number;
templateDocument?: Document | null;
};
export default async function documentCreator(
@@ -51,7 +50,6 @@ export default async function documentCreator(
parentDocumentId,
content,
template,
templateDocument,
fullWidth,
importId,
apiImportId,
@@ -65,11 +63,10 @@ export default async function documentCreator(
): Promise<Document> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const templateId = templateDocument ? templateDocument.id : undefined;
const templateId = template ? template.id : undefined;
const eventData = importId || apiImportId ? { source: "import" } : undefined;
if (state && templateDocument) {
if (state && template) {
throw new Error(
"State cannot be set when creating a document from a template"
);
@@ -90,23 +87,17 @@ export default async function documentCreator(
const titleWithReplacements =
title ??
(templateDocument
? template
? templateDocument.title
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
: "");
(template ? TextHelper.replaceTemplateVariables(template.title, user) : "");
const contentWithReplacements = content
? content
: text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: templateDocument
? template
? templateDocument.content
: SharedProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(templateDocument),
user
)
: template
? SharedProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(template),
user
)
: ProsemirrorHelper.toProsemirror("").toJSON();
const document = Document.build({
@@ -120,15 +111,14 @@ export default async function documentCreator(
updatedAt: updatedAt ?? createdAt,
lastModifiedById: user.id,
createdById: user.id,
template,
templateId,
publishedAt,
importId,
apiImportId,
sourceMetadata,
fullWidth: fullWidth ?? templateDocument?.fullWidth,
icon: icon ?? templateDocument?.icon,
color: color ?? templateDocument?.color,
fullWidth: fullWidth ?? template?.fullWidth,
icon: icon ?? template?.icon,
color: color ?? template?.color,
title: titleWithReplacements,
content: contentWithReplacements,
state,
@@ -147,7 +137,7 @@ export default async function documentCreator(
);
if (publish) {
if (!collectionId && !template) {
if (!collectionId) {
throw new Error("Collection ID is required to publish");
}
+1 -2
View File
@@ -35,7 +35,6 @@ export default async function documentDuplicator(
parentDocumentId,
icon: document.icon,
color: document.color,
template: document.template,
title: title ?? document.title,
content: ProsemirrorHelper.removeMarks(
DocumentHelper.toProsemirror(document),
@@ -104,7 +103,7 @@ export default async function documentDuplicator(
}
}
if (recursive && !document.template) {
if (recursive) {
await duplicateChildDocuments(document, duplicated);
}
+132 -147
View File
@@ -41,170 +41,155 @@ async function documentMover(
collectionChanged,
};
if (document.template && !collectionChanged) {
return result;
}
// Load the current and the next collection upfront and lock them
const collection = await Collection.findByPk(document.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
if (document.template) {
document.collectionId = collectionId;
document.parentDocumentId = null;
document.lastModifiedById = user.id;
document.updatedBy = user;
await document.save({ transaction });
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.findByPk(document.collectionId!, {
let newCollection = collection;
if (collectionChanged && collectionId) {
newCollection = await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
} else if (!collectionId) {
newCollection = null;
}
if (document.publishedAt) {
// Remove the document from the current collection
const response = await collection?.removeDocumentInStructure(document, {
transaction,
save: collectionChanged,
});
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
} else {
newCollection = null;
}
let documentJson = response?.[0];
const fromIndex = response?.[1] || 0;
if (!documentJson) {
documentJson = await document.toNavigationNode({ transaction });
}
if (document.publishedAt) {
// Remove the document from the current collection
const response = await collection?.removeDocumentInStructure(document, {
// if we're reordering from within the same parent
// the original and destination collection are the same,
// so when the initial item is removed above, the list will reduce by 1.
// We need to compensate for this when reordering
const toIndex =
index !== undefined &&
document.parentDocumentId === parentDocumentId &&
document.collectionId === collectionId &&
fromIndex < index
? index - 1
: index;
// Update the properties on the document record, this must be done after
// the toIndex is calculated above
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
document.lastModifiedById = user.id;
document.updatedBy = user;
if (newCollection) {
// Add the document and it's tree to the new collection
await newCollection.addDocumentToStructure(document, toIndex, {
documentJson,
transaction,
});
}
} else {
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
document.lastModifiedById = user.id;
document.updatedBy = user;
}
if (collection && document.publishedAt) {
result.collections.push(collection);
}
// If the collection has changed then we also need to update the properties
// on all of the documents children to reflect the new collectionId
if (collectionChanged) {
// Efficiently find the ID's of all the documents that are children of
// the moved document and update in one query
const childDocumentIds = await document.findAllChildDocumentIds();
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
transaction,
save: collectionChanged,
});
let documentJson = response?.[0];
const fromIndex = response?.[1] || 0;
result.collections.push(newCollection);
if (!documentJson) {
documentJson = await document.toNavigationNode({ transaction });
}
// if we're reordering from within the same parent
// the original and destination collection are the same,
// so when the initial item is removed above, the list will reduce by 1.
// We need to compensate for this when reordering
const toIndex =
index !== undefined &&
document.parentDocumentId === parentDocumentId &&
document.collectionId === collectionId &&
fromIndex < index
? index - 1
: index;
// Update the properties on the document record, this must be done after
// the toIndex is calculated above
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
document.lastModifiedById = user.id;
document.updatedBy = user;
if (newCollection) {
// Add the document and it's tree to the new collection
await newCollection.addDocumentToStructure(document, toIndex, {
documentJson,
transaction,
});
}
} else {
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
document.lastModifiedById = user.id;
document.updatedBy = user;
}
if (collection && document.publishedAt) {
result.collections.push(collection);
}
// If the collection has changed then we also need to update the properties
// on all of the documents children to reflect the new collectionId
if (collectionChanged) {
// Efficiently find the ID's of all the documents that are children of
// the moved document and update in one query
const childDocumentIds = await document.findAllChildDocumentIds();
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
transaction,
});
result.collections.push(newCollection);
await Document.update(
{
collectionId: newCollection.id,
},
{
transaction,
where: {
id: childDocumentIds,
},
}
);
} else {
// document will be moved to drafts
document.publishedAt = null;
// point children's parent to moved document's parent
await Document.update(
{
parentDocumentId: document.parentDocumentId,
},
{
transaction,
where: {
id: childDocumentIds,
},
}
);
}
// We must reload from the database to get the relationship data
const documents = await Document.findAll({
where: {
id: childDocumentIds,
await Document.update(
{
collectionId: newCollection.id,
},
transaction,
});
document.collection = newCollection;
result.documents.push(
...documents.map((doc) => {
if (newCollection) {
doc.collection = newCollection;
}
return doc;
})
{
transaction,
where: {
id: childDocumentIds,
},
}
);
} else {
// document will be moved to drafts
document.publishedAt = null;
// If the document was pinned to the collection then we also need to
// automatically remove the pin to prevent a confusing situation where
// a document is pinned from another collection. Use the command to ensure
// the correct events are emitted.
const pin = await Pin.findOne({
where: {
documentId: document.id,
collectionId: previousCollectionId,
// point children's parent to moved document's parent
await Document.update(
{
parentDocumentId: document.parentDocumentId,
},
transaction,
lock: Transaction.LOCK.UPDATE,
});
await pin?.destroyWithCtx(ctx);
{
transaction,
where: {
id: childDocumentIds,
},
}
);
}
// We must reload from the database to get the relationship data
const documents = await Document.findAll({
where: {
id: childDocumentIds,
},
transaction,
});
document.collection = newCollection;
result.documents.push(
...documents.map((doc) => {
if (newCollection) {
doc.collection = newCollection;
}
return doc;
})
);
// If the document was pinned to the collection then we also need to
// automatically remove the pin to prevent a confusing situation where
// a document is pinned from another collection. Use the command to ensure
// the correct events are emitted.
const pin = await Pin.findOne({
where: {
documentId: document.id,
collectionId: previousCollectionId,
},
transaction,
lock: Transaction.LOCK.UPDATE,
});
await pin?.destroyWithCtx(ctx);
}
result.documents.push(document);
+1 -1
View File
@@ -103,7 +103,7 @@ export default async function documentUpdater(
data: eventData,
};
if (publish && (document.template || cId)) {
if (publish && cId) {
if (!document.collectionId) {
document.collectionId = cId;
}
-13
View File
@@ -52,19 +52,6 @@ describe("#delete", () => {
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete templates", async () => {
const document = await buildDocument({
template: true,
});
const user = await buildUser();
await document.delete(user);
const newDocument = await Document.findByPk(document.id, {
paranoid: false,
});
expect(newDocument?.lastModifiedById).toBe(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete archived", async () => {
const document = await buildDocument({
archivedAt: new Date(),
+9 -23
View File
@@ -73,6 +73,7 @@ import type { APIContext } from "@server/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { SkipChangeset } from "./decorators/Changeset";
import type { HookContext } from "./base/Model";
import Template from "./Template";
export const DOCUMENT_VERSION = 2;
@@ -117,6 +118,7 @@ type AdditionalFindOptions = {
[Op.is]: null,
},
},
template: false,
},
attributes: {
include: [stateIfContentEmpty],
@@ -304,11 +306,11 @@ class Document extends ArchivableModel<
@Default(false)
@Column
template: boolean;
fullWidth: boolean;
@Default(false)
@Column
fullWidth: boolean;
template: boolean;
@Column
insightsEnabled: boolean;
@@ -449,7 +451,6 @@ class Document extends ArchivableModel<
// and so never need to be updated when the title changes
if (
model.archivedAt ||
model.template ||
!model.publishedAt ||
!(
model.changed("title") ||
@@ -476,12 +477,7 @@ class Document extends ArchivableModel<
@AfterCreate
static async addDocumentToCollectionStructure(model: Document) {
if (
model.archivedAt ||
model.template ||
!model.publishedAt ||
!model.collectionId
) {
if (model.archivedAt || !model.publishedAt || !model.collectionId) {
return;
}
@@ -645,10 +641,7 @@ class Document extends ArchivableModel<
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => Document, "templateId")
document: Document;
@ForeignKey(() => Document)
@ForeignKey(() => Template)
@Column(DataType.UUID)
templateId: string;
@@ -903,13 +896,6 @@ class Document extends ArchivableModel<
return !!(this.importId && this.sourceMetadata?.trial);
}
/**
* Returns whether this document is a template created at the workspace level.
*/
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
/**
* Revert the state of the document to match the passed revision.
*
@@ -1037,7 +1023,7 @@ class Document extends ArchivableModel<
this.collectionId = collectionId;
}
if (!this.template && this.collectionId) {
if (this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
@@ -1205,7 +1191,7 @@ class Document extends ArchivableModel<
}
}
if (!this.template && this.publishedAt && collection?.isActive) {
if (this.publishedAt && collection?.isActive) {
await collection.addDocumentToStructure(this, undefined, {
includeArchived: true,
transaction,
@@ -1234,7 +1220,7 @@ class Document extends ArchivableModel<
this.sequelize.transaction(async (transaction: Transaction) => {
let deleted = false;
if (!this.template && this.collectionId) {
if (this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
includeDocumentStructure: true,
transaction,
+290
View File
@@ -0,0 +1,290 @@
import { isUUID } from "class-validator";
import type {
Identifier,
InferAttributes,
InferCreationAttributes,
NonNullFindOptions,
FindOptions,
} from "sequelize";
import { EmptyResultError } from "sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
Length as SimpleLength,
DefaultScope,
Default,
HasMany,
Unique,
Scopes,
BeforeValidate,
IsDate,
} from "sequelize-typescript";
import slugify from "slugify";
import type { ProsemirrorData } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { DocumentValidation } from "@shared/validations";
import { generateUrlId } from "@server/utils/url";
import Collection from "./Collection";
import Revision from "./Revision";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
type AdditionalFindOptions = {
userId?: string;
includeState?: boolean;
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
include: [
{
association: "createdBy",
paranoid: false,
},
{
association: "updatedBy",
paranoid: false,
},
],
where: {
template: true,
},
attributes: {
exclude: ["state"],
},
}))
@Scopes(() => ({
withMembership: (userId: string, paranoid = true) => ({
include: [
{
model: userId
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
: Collection,
as: "collection",
paranoid,
},
],
}),
withCollection: {
include: [
{
model: Collection,
as: "collection",
},
],
},
}))
@Table({ tableName: "documents", modelName: "template" })
@Fix
class Template extends ParanoidModel<
InferAttributes<Template>,
Partial<InferCreationAttributes<Template>>
> {
/** The namespace to use for events. */
static eventNamespace = "templates";
@SimpleLength({
min: 10,
max: 10,
msg: `urlId must be 10 characters`,
})
@Unique
@Column
urlId: string;
@Length({
max: DocumentValidation.maxTitleLength,
msg: `Template title must be ${DocumentValidation.maxTitleLength} characters or less`,
})
@Column
title: string;
@Default(false)
@Column
fullWidth: boolean;
@Default(true)
@Column
template: boolean;
/** The version of the editor last used to edit this template. */
@SimpleLength({
max: 255,
msg: `editorVersion must be 255 characters or less`,
})
@Column
editorVersion: string | null;
/** An icon to use as the template icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
})
@Column
icon: string | null;
/** The color of the icon. */
@IsHexColor
@Column
color: string | null;
/** The likely language of the template, in ISO 639-1 format. */
@Column
language: string | null;
/**
* The content of the template as JSON, this is a snapshot at the last time the state was saved.
*/
@Column(DataType.JSONB)
content: ProsemirrorData | null;
// associations
@BelongsTo(() => User, "lastModifiedById")
updatedBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
lastModifiedById: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId?: string | null;
@HasMany(() => Revision, "documentId")
revisions: Revision[];
/** Whether the template is published, and if so when. */
@IsDate
@Column
publishedAt: Date | null;
// getters
/** The frontend path to this template. */
get path() {
if (!this.title) {
return `/settings/templates/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `/settings/templates/${slugifiedTitle}-${this.urlId}`;
}
/**
* Returns whether this is a workspace template.
*
* @returns boolean
*/
get isWorkspaceTemplate() {
return !this.collectionId;
}
@BeforeValidate
static createUrlId(model: Template) {
return (model.urlId = model.urlId || generateUrlId());
}
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
*
* @param id uuid or urlId
* @param options FindOptions
* @returns A promise resolving to a template instance or null
*/
static async findByPk(
id: Identifier,
options?: NonNullFindOptions<Template> & AdditionalFindOptions
): Promise<Template>;
static async findByPk(
id: Identifier,
options?: FindOptions<Template> & AdditionalFindOptions
): Promise<Template | null>;
static async findByPk(
id: Identifier,
options: (NonNullFindOptions<Template> | FindOptions<Template>) &
AdditionalFindOptions = {}
): Promise<Template | null> {
if (typeof id !== "string") {
return null;
}
const { includeState, userId, ...rest } = options;
// allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"defaultScope",
{
method: ["withMembership", userId, rest.paranoid],
},
]);
if (isUUID(id)) {
const template = await scope.findOne({
where: {
id,
},
...rest,
rejectOnEmpty: false,
});
if (!template && rest.rejectOnEmpty) {
throw new EmptyResultError(`Template doesn't exist with id: ${id}`);
}
return template;
}
const match = id.match(UrlHelper.SLUG_URL_REGEX);
if (match) {
const template = await scope.findOne({
where: {
urlId: match[1],
},
...rest,
rejectOnEmpty: false,
});
if (!template && rest.rejectOnEmpty) {
throw new EmptyResultError(`Template doesn't exist with id: ${id}`);
}
return template;
}
return null;
}
}
export default Template;
+3 -2
View File
@@ -15,6 +15,7 @@ import { determineIconType } from "@shared/utils/icon";
import { parser, serializer, schema } from "@server/editor";
import { addTags } from "@server/logging/tracer";
import { trace } from "@server/logging/tracing";
import type { Template } from "@server/models";
import { Collection, Document, Revision } from "@server/models";
import type { MentionAttrs } from "./ProsemirrorHelper";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
@@ -80,7 +81,7 @@ export class DocumentHelper {
* @returns The document content as a plain JSON object
*/
static async toJSON(
document: Document | Revision | Collection,
document: Document | Revision | Collection | Template,
options?: {
/** The team context */
teamId?: string;
@@ -112,7 +113,7 @@ export class DocumentHelper {
} else if (document instanceof Collection) {
doc = parser.parse(document.description ?? "");
} else {
doc = parser.parse(document.text ?? "");
doc = parser.parse("text" in document ? (document.text ?? "") : "");
}
if (doc && options?.signedUrls && options?.teamId) {
+2
View File
@@ -56,6 +56,8 @@ export { default as Team } from "./Team";
export { default as TeamDomain } from "./TeamDomain";
export { default as Template } from "./Template";
export { default as User } from "./User";
export { default as UserAuthentication } from "./UserAuthentication";
+5 -54
View File
@@ -24,10 +24,6 @@ allow(User, "read", Document, (actor, document) =>
DocumentPermission.Admin,
]),
and(!!document?.isDraft, actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "readTemplate", actor.team)
),
can(actor, "readDocument", document?.collection)
)
)
@@ -53,7 +49,6 @@ allow(User, "download", Document, (actor, document) =>
allow(User, "comment", Document, (actor, document) =>
and(
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
// TODO: We'll introduce a separate permission for commenting
or(
@@ -71,7 +66,6 @@ allow(
(actor, document) =>
and(
//
!document?.template,
can(actor, "read", document)
)
);
@@ -79,7 +73,6 @@ allow(
allow(User, "share", Document, (actor, document) =>
and(
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
can(actor, "read", document),
or(!document?.collection, can(actor, "share", document?.collection))
@@ -98,14 +91,7 @@ allow(User, "update", Document, (actor, document) =>
]),
or(
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
and(!!document?.isDraft && actor.id === document?.createdById)
)
)
)
@@ -121,7 +107,6 @@ allow(User, "publish", Document, (actor, document) =>
allow(User, "manageUsers", Document, (actor, document) =>
and(
!document?.template,
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
@@ -139,14 +124,7 @@ allow(User, "duplicate", Document, (actor, document) =>
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
can(actor, "updateDocument", document?.collection),
!!document?.isDraft && actor.id === document?.createdById,
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
!!document?.isDraft && actor.id === document?.createdById
)
)
);
@@ -161,14 +139,7 @@ allow(User, "move", Document, (actor, document) =>
]),
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById),
and(!!document?.isDraft && !document?.collection),
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
and(!!document?.isDraft && !document?.collection)
)
)
);
@@ -177,7 +148,6 @@ allow(User, "createChildDocument", Document, (actor, document) =>
and(
//
!document?.isDraft,
!document?.template,
can(actor, "update", document)
)
);
@@ -185,7 +155,6 @@ allow(User, "createChildDocument", Document, (actor, document) =>
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
and(
!document?.isDraft,
!document?.template,
!actor.isGuest,
can(actor, "update", document),
can(actor, "update", document?.collection)
@@ -196,7 +165,6 @@ allow(User, "pinToHome", Document, (actor, document) =>
and(
//
!document?.isDraft,
!document?.template,
!!document?.isActive,
isTeamAdmin(actor, document),
isTeamMutable(actor)
@@ -211,11 +179,7 @@ allow(User, "delete", Document, (actor, document) =>
or(
can(actor, "unarchive", document),
can(actor, "update", document),
and(
!document?.isWorkspaceTemplate,
!document?.collection,
actor.id === document?.createdById
)
and(!document?.collection, actor.id === document?.createdById)
)
)
);
@@ -231,11 +195,7 @@ allow(User, "restore", Document, (actor, document) =>
DocumentPermission.Admin,
]),
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "updateTemplate", actor.team)
)
and(!!document?.isDraft && actor.id === document?.createdById)
)
)
);
@@ -251,7 +211,6 @@ allow(User, "permanentDelete", Document, (actor, document) =>
allow(User, "archive", Document, (actor, document) =>
and(
!document?.template,
!document?.isDraft,
!!document?.isActive,
can(actor, "update", document),
@@ -265,7 +224,6 @@ allow(User, "archive", Document, (actor, document) =>
allow(User, "unarchive", Document, (actor, document) =>
and(
!document?.template,
!document?.isDraft,
!document?.isDeleted,
!!document?.archivedAt,
@@ -299,13 +257,6 @@ allow(User, "unpublish", Document, (user, document) => {
return false;
}
if (
document.isWorkspaceTemplate &&
(user.id === document.createdById || can(user, "updateTemplate", user.team))
) {
return true;
}
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
+1
View File
@@ -23,6 +23,7 @@ import "./star";
import "./subscription";
import "./user";
import "./team";
import "./template";
import "./group";
import "./webhookSubscription";
import "./userMembership";
+67
View File
@@ -0,0 +1,67 @@
import { Template, User, Team } from "@server/models";
import { allow, can } from "./cancan";
import { and, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, ["createTemplate", "updateTemplate"], Team, (actor, team) =>
and(
//
actor.isAdmin,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
allow(User, "read", Template, (actor, template) =>
and(
isTeamModel(actor, template),
or(
and(!!template?.isWorkspaceTemplate, can(actor, "read", actor.team)),
can(actor, "readDocument", template?.collection)
)
)
);
allow(User, "listRevisions", Template, (actor, template) =>
or(
and(can(actor, "read", template), !actor.isGuest),
and(can(actor, "update", template), actor.isGuest)
)
);
allow(User, ["update", "move", "duplicate"], Template, (actor, template) =>
and(
can(actor, "read", template),
isTeamMutable(actor),
or(
and(
!!template?.isWorkspaceTemplate,
can(actor, "updateTemplate", actor.team)
),
can(actor, "update", template?.collection)
)
)
);
allow(User, "delete", Template, (actor, template) =>
and(
//
can(actor, "update", template),
!template?.isDeleted
)
);
allow(User, "restore", Template, (actor, template) =>
and(
//
!!template?.isDeleted,
isTeamModel(actor, template),
isTeamMutable(actor),
or(
and(
!!template?.isWorkspaceTemplate,
can(actor, "updateTemplate", actor.team)
),
can(actor, "update", template?.collection)
)
)
);
-1
View File
@@ -99,7 +99,6 @@ async function presentDocument(
res.updatedBy = presentUser(document.updatedBy);
res.collaboratorIds = document.collaboratorIds;
res.templateId = document.templateId;
res.template = document.template;
res.insightsEnabled = document.insightsEnabled;
res.popularityScore = document.popularityScore;
res.sourceMetadata = document.sourceMetadata
+2
View File
@@ -26,6 +26,7 @@ import presentShare from "./share";
import presentStar from "./star";
import presentSubscription from "./subscription";
import presentTeam from "./team";
import presentTemplate from "./template";
import presentUser from "./user";
import presentView from "./view";
import presentEmoji from "./emoji";
@@ -60,6 +61,7 @@ export {
presentStar,
presentSubscription,
presentTeam,
presentTemplate,
presentUser,
presentView,
presentEmoji,
+24
View File
@@ -0,0 +1,24 @@
import type { Template } from "@server/models";
import presentUser from "./user";
function presentTemplate(template: Template) {
return {
id: template.id,
url: template.path,
urlId: template.urlId,
title: template.title,
data: template.content,
icon: template.icon,
color: template.color,
createdAt: template.createdAt,
createdBy: presentUser(template.createdBy),
updatedAt: template.updatedAt,
updatedBy: presentUser(template.updatedBy),
deletedAt: template.deletedAt,
publishedAt: template.publishedAt,
fullWidth: template.fullWidth,
collectionId: template.collectionId,
};
}
export default presentTemplate;
@@ -18,6 +18,7 @@ import {
UserMembership,
User,
Import,
Template,
} from "@server/models";
import { cannot } from "@server/policies";
import {
@@ -42,6 +43,37 @@ import type { Event } from "../../types";
export default class WebsocketsProcessor {
public async perform(event: Event, socketio: Server) {
switch (event.name) {
case "templates.create":
case "templates.update":
case "templates.restore": {
const template = await Template.findByPk(event.modelId, {
paranoid: false,
});
if (!template) {
return;
}
const channels = await this.getTemplateEventChannels(event, template);
return socketio.to(channels).emit("entities", {
event: event.name,
invalidatedPolicies: [template.id],
templateIds: [
{
id: template.id,
updatedAt: template.updatedAt,
},
],
});
}
case "templates.delete": {
return socketio.to(`team-${event.teamId}`).emit("entities", {
event: event.name,
modelId: event.modelId,
});
}
case "documents.create":
case "documents.publish":
case "documents.restore": {
@@ -918,8 +950,6 @@ export default class WebsocketsProcessor {
channels.push(
...this.getCollectionEventChannels(event, document.collection)
);
} else if (document.isWorkspaceTemplate) {
channels.push(`team-${document.teamId}`);
} else {
channels.push(`collection-${document.collectionId}`);
}
@@ -948,4 +978,29 @@ export default class WebsocketsProcessor {
return uniq(channels);
}
private async getTemplateEventChannels(
event: Event,
template: Template
): Promise<string[]> {
const channels = [];
if (event.actorId) {
channels.push(`user-${event.actorId}`);
}
if (template.collectionId) {
if (template.collection) {
channels.push(
...this.getCollectionEventChannels(event, template.collection)
);
} else {
channels.push(`collection-${template.collectionId}`);
}
} else {
channels.push(`team-${template.teamId}`);
}
return uniq(channels);
}
}
@@ -31,7 +31,6 @@ export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
const documents = await Document.scope("withDrafts").findAll({
where: {
collectionId: props.collectionId,
template: false,
publishedAt: {
[Op.is]: null,
},
-1
View File
@@ -149,7 +149,6 @@ export default class ExportJSONTask extends ExportTask {
? document.publishedAt.toISOString()
: null,
fullWidth: document.fullWidth,
template: document.template,
parentDocumentId: document.parentDocumentId,
};
+9 -238
View File
@@ -37,6 +37,7 @@ import {
buildTeam,
buildGroup,
buildAdmin,
buildTemplate,
} from "@server/test/factories";
import { getTestServer, withAPIContext } from "@server/test/support";
@@ -2768,58 +2769,6 @@ describe("#documents.move", () => {
expect(res.status).toEqual(403);
});
it("should move a template to workspace", async () => {
const user = await buildAdmin();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
template: true,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toBeNull();
expect(body.policies[0].abilities.move).toBeTruthy();
});
it("should move a workspace template to collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toEqual(collection.id);
expect(body.policies[0].abilities.move).toBeTruthy();
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.move");
expect(res.status).toEqual(401);
@@ -3106,92 +3055,6 @@ describe("#documents.restore", () => {
expect(body.data.collectionId).toEqual(anotherCollection.id);
});
it("should allow restore of collection templates", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const template = await buildDocument({
template: true,
userId: user.id,
collectionId: collection.id,
teamId: team.id,
});
await template.delete(user);
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(collection.id);
});
it("should allow restore of templates from a deleted collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const anotherCollection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const template = await buildDocument({
template: true,
userId: user.id,
collectionId: collection.id,
teamId: team.id,
});
await template.delete(user);
await collection.destroy({ hooks: false });
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: template.id,
collectionId: anotherCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(anotherCollection.id);
});
it("should allow restore of workspace templates", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const template = await buildDocument({
template: true,
userId: user.id,
teamId: team.id,
collectionId: null,
});
await template.delete(user);
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(null);
});
it("should not allow restore of documents to a deleted collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
@@ -3499,12 +3362,12 @@ describe("#documents.import", () => {
describe("#documents.create", () => {
it("should replace template variables when a doc is created from a template", async () => {
const user = await buildUser();
const template = await buildDocument({
const text = `This document was created by {author} on {date}`;
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "Created by user {author} on {date}",
text,
});
const res = await server.post("/api/documents.create", {
body: {
@@ -3518,32 +3381,10 @@ describe("#documents.create", () => {
TextHelper.replaceTemplateVariables(template.title, user)
);
expect(body.data.text).toEqual(
TextHelper.replaceTemplateVariables(template.text, user)
TextHelper.replaceTemplateVariables(text, user)
);
});
it("should retain template variables when a template is created from another template", async () => {
const user = await buildUser();
const template = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "Created by user {author} on {date}",
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
templateId: template.id,
template: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual(template.title);
expect(body.data.text).toEqual(template.text);
});
it("should create a document with empty title if no title is explicitly passed", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.create", {
@@ -3557,14 +3398,12 @@ describe("#documents.create", () => {
expect(body.data.title).toEqual("");
});
it("should use template title when doc is supposed to be created using the template and title is not explicitly passed", async () => {
it("should use template title when doc is created using a template and title is not explicitly passed", async () => {
const user = await buildUser();
const template = await buildDocument({
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "template text",
});
const res = await server.post("/api/documents.create", {
body: {
@@ -3575,15 +3414,13 @@ describe("#documents.create", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual(template.title);
expect(body.data.text).toEqual(template.text);
});
it("should override template title when doc title is explicitly passed", async () => {
const user = await buildUser();
const template = await buildDocument({
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
});
const res = await server.post("/api/documents.create", {
@@ -3600,10 +3437,9 @@ describe("#documents.create", () => {
it("should override template text when doc text is explicitly passed", async () => {
const user = await buildUser();
const template = await buildDocument({
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
template: true,
text: "template text",
});
const res = await server.post("/api/documents.create", {
@@ -3752,7 +3588,6 @@ describe("#documents.create", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.template).toBe(true);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toBeNull();
});
@@ -3976,39 +3811,6 @@ describe("#documents.update", () => {
expect(body.data.text).toBe("Updated text");
});
it("should successfully publish a draft template without collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const document = await buildDraftDocument({
title: "title",
text: "text",
teamId: team.id,
userId: user.id,
collectionId: null,
template: true,
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
title: "Updated title",
text: "Updated text",
collectionId: collection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toBe(collection.id);
expect(body.data.title).toBe("Updated title");
expect(body.data.text).toBe("Updated text");
expect(body.data.publishedAt).toBeTruthy();
});
it("should not allow publishing by another collection's user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
@@ -4099,37 +3901,6 @@ describe("#documents.update", () => {
expect(body.data.color).toBeNull();
});
it("should not add template to collection structure when publishing", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
template: true,
publishedAt: null,
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: template.id,
title: "Updated title",
text: "Updated text",
publish: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBe("Updated title");
expect(body.data.text).toBe("Updated text");
expect(body.data.publishedAt).toBeTruthy();
await collection.reload();
expect(collection.documentStructure).toBe(null);
});
it("should allow publishing document in private collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
+14 -41
View File
@@ -52,6 +52,7 @@ import {
Event,
Revision,
SearchQuery,
Template,
User,
View,
UserMembership,
@@ -65,10 +66,11 @@ import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize, can, cannot } from "@server/policies";
import { authorize, cannot } from "@server/policies";
import {
presentDocument,
presentPolicies,
presentTemplate,
presentMembership,
presentUser,
presentGroupMembership,
@@ -103,7 +105,6 @@ router.post(
const {
sort,
direction,
template,
collectionId,
backlinkDocumentId,
parentDocumentId,
@@ -131,12 +132,6 @@ router.post(
where[Op.and].push({ archivedAt: { [Op.eq]: null } });
}
if (template) {
where[Op.and].push({
template: true,
});
}
// if a specific user is passed then add to filters. If the user doesn't
// exist in the team then nothing will be returned, so no need to check auth
if (createdById) {
@@ -170,12 +165,7 @@ router.post(
} else if (!backlinkDocumentId) {
const collectionIds = await user.collectionIds();
where[Op.and].push({
collectionId:
template && can(user, "readTemplate", user.team)
? {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
}
: collectionIds,
collectionId: collectionIds,
});
}
@@ -954,14 +944,12 @@ router.post(
})
: undefined;
// In case of workspace templates, both source and destination collections are undefined.
if (!document.isWorkspaceTemplate && !destCollection?.isActive) {
if (!destCollection?.isActive) {
throw ValidationError(
"Unable to restore, the collection may have been deleted or archived"
);
}
// Skip this for workspace templates and drafts of a deleted collection as they won't have sourceCollectionId.
if (sourceCollectionId && sourceCollectionId !== destCollectionId) {
authorize(user, "updateDocument", srcCollection);
await srcCollection?.removeDocumentInStructure(document, {
@@ -970,10 +958,7 @@ router.post(
});
}
if (document.deletedAt && document.isWorkspaceTemplate) {
authorize(user, "restore", document);
await document.restoreWithCtx(ctx, { name: "restore" });
} else if (document.deletedAt) {
if (document.deletedAt) {
authorize(user, "restore", document);
authorize(user, "updateDocument", destCollection);
@@ -1244,30 +1229,28 @@ router.post(
authorize(user, "createTemplate", user.team);
}
const document = await Document.createWithCtx(ctx, {
const template = await Template.createWithCtx(ctx, {
editorVersion: original.editorVersion,
collectionId,
teamId: user.teamId,
publishedAt: publish ? new Date() : null,
lastModifiedById: user.id,
createdById: user.id,
template: true,
icon: original.icon,
color: original.color,
title: original.title,
text: original.text,
content: original.content,
});
// reload to get all of the data needed to present (user, collection etc)
const reloaded = await Document.findByPk(document.id, {
const reloaded = await Template.findByPk(template.id, {
userId: user.id,
transaction,
});
invariant(reloaded, "document not found");
invariant(reloaded, "template not found");
ctx.body = {
data: await presentDocument(ctx, reloaded),
data: presentTemplate(reloaded),
policies: presentPolicies(user, [reloaded]),
};
}
@@ -1304,7 +1287,7 @@ router.post(
authorize(user, "publish", document);
}
if (!document.collectionId && !document.isWorkspaceTemplate) {
if (!document.collectionId) {
assertPresent(
collectionId,
"collectionId is required to publish a draft without collection"
@@ -1324,8 +1307,6 @@ router.post(
}
);
authorize(user, "createChildDocument", parentDocument, { collection });
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
} else {
authorize(user, "createDocument", collection);
}
@@ -1373,8 +1354,6 @@ router.post(
if (collection) {
authorize(user, "updateDocument", collection);
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
}
if (parentDocumentId) {
@@ -1442,8 +1421,6 @@ router.post(
transaction,
});
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
} else {
throw InvalidRequestError("collectionId is required to move a document");
}
@@ -1671,7 +1648,6 @@ router.post(
parentDocumentId,
fullWidth,
templateId,
template,
createdAt,
} = ctx.input.body;
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
@@ -1703,18 +1679,16 @@ router.post(
transaction,
});
authorize(user, "createDocument", collection);
} else if (!!template && !collectionId) {
authorize(user, "createTemplate", user.team);
}
let templateDocument: Document | null | undefined;
let template: Template | null | undefined;
if (templateId) {
templateDocument = await Document.findByPk(templateId, {
template = await Template.findByPk(templateId, {
userId: user.id,
transaction,
});
authorize(user, "read", templateDocument);
authorize(user, "read", template);
}
// Pre-process text to convert bare embed URLs to markdown link format
@@ -1737,7 +1711,6 @@ router.post(
index,
collectionId: collection?.id,
parentDocumentId,
templateDocument,
template,
fullWidth,
editorVersion,
-6
View File
@@ -95,9 +95,6 @@ export const DocumentsListSchema = BaseSchema.extend({
/** Id of the parent document to which the document belongs */
parentDocumentId: z.uuid().nullish(),
/** Boolean which denotes whether the document is a template */
template: z.boolean().optional(),
/** Document statuses to include in results */
statusFilter: z.enum(StatusFilter).array().optional(),
}),
@@ -430,9 +427,6 @@ export const DocumentsCreateSchema = BaseSchema.extend({
/** Boolean to denote if the document should occupy full width */
fullWidth: z.boolean().optional(),
/** Whether this should be considered a template */
template: z.boolean().optional(),
}),
}).refine(
(req) =>
+2
View File
@@ -45,6 +45,7 @@ import stars from "./stars";
import subscriptions from "./subscriptions";
import suggestions from "./suggestions";
import teams from "./teams";
import templates from "./templates";
import urls from "./urls";
import userMemberships from "./userMemberships";
import users from "./users";
@@ -100,6 +101,7 @@ router.use("/", stars.routes());
router.use("/", subscriptions.routes());
router.use("/", suggestions.routes());
router.use("/", teams.routes());
router.use("/", templates.routes());
router.use("/", integrations.routes());
router.use("/", notifications.routes());
router.use("/", oauthAuthentications.routes());
+1
View File
@@ -0,0 +1 @@
export { default } from "./templates";
+94
View File
@@ -0,0 +1,94 @@
import { z } from "zod";
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
import { zodIconType, zodIdType } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const TemplatesSortParamsSchema = z.object({
/** Specifies the attributes by which templates will be sorted in the list */
sort: z
.string()
.refine((val) =>
[
"createdAt",
"updatedAt",
"publishedAt",
"title",
"collectionId",
].includes(val)
)
.default("updatedAt"),
/** Specifies the sort order with respect to sort field */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
});
export const TemplatesListSchema = BaseSchema.extend({
body: TemplatesSortParamsSchema.extend({
/** Id of the collection to which the template belongs */
collectionId: z.string().uuid().optional(),
}),
});
export const TemplatesCreateSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid().optional(),
collectionId: z.string().uuid().optional(),
title: z.string().min(1).max(255),
data: ProsemirrorSchema(),
icon: zodIconType().nullish(),
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.nullish(),
}),
});
export type TemplatesCreateReq = z.infer<typeof TemplatesCreateSchema>;
export type TemplatesListReq = z.infer<typeof TemplatesListSchema>;
export const TemplatesInfoSchema = BaseSchema.extend({
body: z.object({
id: zodIdType(),
}),
});
export type TemplatesInfoReq = z.infer<typeof TemplatesInfoSchema>;
export const TemplatesDeleteSchema = BaseSchema.extend({
body: z.object({
id: zodIdType(),
}),
});
export type TemplatesDeleteReq = z.infer<typeof TemplatesDeleteSchema>;
export const TemplatesDuplicateSchema = BaseSchema.extend({
body: z.object({
id: zodIdType(),
title: z.string().optional(),
collectionId: z.string().uuid().nullish(),
}),
});
export type TemplatesDuplicateReq = z.infer<typeof TemplatesDuplicateSchema>;
export const TemplatesUpdateSchema = BaseSchema.extend({
body: z.object({
id: zodIdType(),
title: z.string().optional(),
data: ProsemirrorSchema().optional(),
icon: zodIconType().nullish(),
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.nullish(),
fullWidth: z.boolean().optional(),
collectionId: z.string().uuid().nullish(),
}),
});
export type TemplatesUpdateReq = z.infer<typeof TemplatesUpdateSchema>;
@@ -0,0 +1,481 @@
import {
buildAdmin,
buildUser,
buildTemplate,
buildCollection,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#templates.list", () => {
it("should list templates", async () => {
const user = await buildUser();
await buildTemplate(); // create a template that shouldn't be included
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/templates.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(template.id);
});
it("should list templates in collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/templates.list", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(template.id);
});
it("should require authentication", async () => {
const res = await server.post("/api/templates.list");
expect(res.status).toEqual(401);
});
});
describe("#templates.info", () => {
it("should return template data", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/templates.info", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(template.id);
expect(body.data.title).toEqual(template.title);
});
it("should require authentication", async () => {
const res = await server.post("/api/templates.info");
expect(res.status).toEqual(401);
});
it("should fail for invalid template id", async () => {
const user = await buildUser();
const res = await server.post("/api/templates.info", {
body: {
token: user.getJwtToken(),
id: "invalid",
},
});
expect(res.status).toEqual(400);
});
});
describe("#templates.update", () => {
it("should update template title", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
title: "Original title",
});
const res = await server.post("/api/templates.update", {
body: {
token: user.getJwtToken(),
id: template.id,
title: "New title",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual("New title");
});
it("should update template content", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
text: "Original content",
});
const data = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "hello",
},
],
},
],
};
const res = await server.post("/api/templates.update", {
body: {
token: user.getJwtToken(),
id: template.id,
data,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.data).toEqual(data);
});
it("should allow admin to move template to another accessible collection", async () => {
const admin = await buildAdmin();
const template = await buildTemplate({
userId: admin.id,
teamId: admin.teamId,
});
const targetCollection = await buildCollection({
userId: admin.id,
teamId: admin.teamId,
});
const res = await server.post("/api/templates.update", {
body: {
token: admin.getJwtToken(),
id: template.id,
collectionId: targetCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toEqual(targetCollection.id);
});
it("should not allow moving template to a collection user has no access to", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
// Collection created by another user with no default permission
const inaccessibleCollection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const res = await server.post("/api/templates.update", {
body: {
token: user.getJwtToken(),
id: template.id,
collectionId: inaccessibleCollection.id,
},
});
expect(res.status).toEqual(403);
});
it("should not allow non-admin to move template to workspace scope", async () => {
const admin = await buildAdmin();
// Create template as admin so the non-admin user's team has it
const template = await buildTemplate({
userId: admin.id,
teamId: admin.teamId,
});
// Create a non-admin member on the same team who has collection access
// but is not a team admin
const user = await buildUser({ teamId: admin.teamId });
const res = await server.post("/api/templates.update", {
body: {
token: user.getJwtToken(),
id: template.id,
collectionId: null,
},
});
expect(res.status).toEqual(403);
});
it("should allow admin to move template to workspace scope", async () => {
const admin = await buildAdmin();
const template = await buildTemplate({
userId: admin.id,
teamId: admin.teamId,
});
const res = await server.post("/api/templates.update", {
body: {
token: admin.getJwtToken(),
id: template.id,
collectionId: null,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toBeNull();
});
it("should fail with status 400 bad request when id is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/templates.update", {
body: {
token: user.getJwtToken(),
title: "New title",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Must be a valid UUID or slug");
});
it("should require authentication", async () => {
const res = await server.post("/api/templates.update");
expect(res.status).toEqual(401);
});
});
describe("#templates.duplicate", () => {
it("should duplicate template", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
title: "test",
});
const res = await server.post("/api/templates.duplicate", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).not.toEqual(template.id);
expect(body.data.title).toEqual(template.title);
expect(body.data.data).toEqual(template.content);
});
it("should duplicate template with new title", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/templates.duplicate", {
body: {
token: user.getJwtToken(),
id: template.id,
title: "New title",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).not.toEqual(template.id);
expect(body.data.title).toEqual("New title");
expect(body.data.data).toEqual(template.content);
});
it("should allow admin to duplicate to another accessible collection", async () => {
const admin = await buildAdmin();
const template = await buildTemplate({
userId: admin.id,
teamId: admin.teamId,
});
const targetCollection = await buildCollection({
userId: admin.id,
teamId: admin.teamId,
});
const res = await server.post("/api/templates.duplicate", {
body: {
token: admin.getJwtToken(),
id: template.id,
collectionId: targetCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toEqual(targetCollection.id);
});
it("should not allow duplicating to a collection user has no access to", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
// Collection created by another user with no default permission
const inaccessibleCollection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const res = await server.post("/api/templates.duplicate", {
body: {
token: user.getJwtToken(),
id: template.id,
collectionId: inaccessibleCollection.id,
},
});
expect(res.status).toEqual(403);
});
it("should not allow non-admin to duplicate to workspace scope", async () => {
const admin = await buildAdmin();
const template = await buildTemplate({
userId: admin.id,
teamId: admin.teamId,
});
// Non-admin member on the same team
const user = await buildUser({ teamId: admin.teamId });
const res = await server.post("/api/templates.duplicate", {
body: {
token: user.getJwtToken(),
id: template.id,
collectionId: null,
},
});
expect(res.status).toEqual(403);
});
it("should allow admin to duplicate to workspace scope", async () => {
const admin = await buildAdmin();
const template = await buildTemplate({
userId: admin.id,
teamId: admin.teamId,
});
const res = await server.post("/api/templates.duplicate", {
body: {
token: admin.getJwtToken(),
id: template.id,
collectionId: null,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toBeNull();
});
it("should set publishedAt on duplicated template", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/templates.duplicate", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.publishedAt).toBeTruthy();
});
it("should require authentication", async () => {
const res = await server.post("/api/templates.duplicate");
expect(res.status).toEqual(401);
});
it("should fail for invalid template id", async () => {
const user = await buildUser();
const res = await server.post("/api/templates.duplicate", {
body: {
token: user.getJwtToken(),
id: "invalid",
},
});
expect(res.status).toEqual(400);
});
});
describe("#templates.delete", () => {
it("should delete template", async () => {
const user = await buildUser();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/templates.delete", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
it("should fail with status 400 bad request when id is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/templates.delete", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Must be a valid UUID or slug");
});
it("should require authentication", async () => {
const res = await server.post("/api/templates.delete");
expect(res.status).toEqual(401);
});
});
+313
View File
@@ -0,0 +1,313 @@
import Router from "koa-router";
import type { WhereOptions } from "sequelize";
import { Op } from "sequelize";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Collection, Template } from "@server/models";
import { authorize } from "@server/policies";
import { presentPolicies, presentTemplate } from "@server/presenters";
import type { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"templates.create",
auth(),
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
validate(T.TemplatesCreateSchema),
transaction(),
async (ctx: APIContext<T.TemplatesCreateReq>) => {
const { id, title, data, icon, color, collectionId } = ctx.input.body;
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
authorize(user, "createTemplate", user.team);
let collection;
if (collectionId) {
collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
}
let template = await Template.createWithCtx(ctx, {
id,
title,
icon,
color,
content: data,
collectionId: collection?.id,
publishedAt: new Date(),
createdById: user.id,
lastModifiedById: user.id,
teamId: user.teamId,
editorVersion,
});
template = await Template.findByPk(template.id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
ctx.body = {
data: presentTemplate(template),
policies: presentPolicies(user, [template]),
};
}
);
router.post(
"templates.list",
auth(),
pagination(),
validate(T.TemplatesListSchema),
async (ctx: APIContext<T.TemplatesListReq>) => {
const { sort, direction, collectionId } = ctx.input.body;
const { user } = ctx.state.auth;
const where: WhereOptions<Template> & {
[Op.and]: WhereOptions<Template>[];
} = {
teamId: user.teamId,
[Op.and]: [
{
deletedAt: {
[Op.eq]: null,
},
},
],
};
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
} else {
where[Op.and].push({
[Op.or]: [
{
collectionId: {
[Op.eq]: null,
},
},
{
collectionId: await user.collectionIds(),
},
],
});
}
const [templates, total] = await Promise.all([
Template.scope([
"defaultScope",
{
method: ["withMembership", user.id],
},
]).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Template.count({ where }),
]);
const data = await Promise.all(
templates.map((template) => presentTemplate(template))
);
const policies = presentPolicies(user, templates);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data,
policies,
};
}
);
router.post(
"templates.info",
auth(),
validate(T.TemplatesInfoSchema),
async (ctx: APIContext<T.TemplatesInfoReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const template = await Template.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
});
authorize(user, "read", template);
ctx.body = {
data: presentTemplate(template),
policies: presentPolicies(user, [template]),
};
}
);
router.post(
"templates.delete",
auth(),
validate(T.TemplatesDeleteSchema),
transaction(),
async (ctx: APIContext<T.TemplatesDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const template = await Template.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
authorize(user, "delete", template);
await template.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
router.post(
"templates.restore",
auth(),
validate(T.TemplatesInfoSchema),
transaction(),
async (ctx: APIContext<T.TemplatesInfoReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const template = await Template.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
paranoid: false,
});
authorize(user, "restore", template);
await template.restoreWithCtx(ctx);
ctx.body = {
data: presentTemplate(template),
policies: presentPolicies(user, [template]),
};
}
);
router.post(
"templates.duplicate",
auth(),
validate(T.TemplatesDuplicateSchema),
transaction(),
async (ctx: APIContext<T.TemplatesDuplicateReq>) => {
const { transaction } = ctx.state;
const { id, title, collectionId } = ctx.input.body;
const { user } = ctx.state.auth;
const original = await Template.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
authorize(user, "duplicate", original);
const targetCollectionId =
collectionId === undefined ? original.collectionId : collectionId;
if (targetCollectionId) {
const collection = await Collection.findByPk(targetCollectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
} else {
authorize(user, "createTemplate", user.team);
}
let template = await Template.createWithCtx(ctx, {
title: title ?? original.title,
createdById: user.id,
lastModifiedById: user.id,
teamId: user.teamId,
collectionId: targetCollectionId,
publishedAt: new Date(),
content: original.content,
icon: original.icon,
color: original.color,
fullWidth: original.fullWidth,
});
// reload to get all of the data needed to present (user, collection etc)
template = await Template.findByPk(template.id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
ctx.body = {
data: presentTemplate(template),
policies: presentPolicies(user, [template]),
};
}
);
router.post(
"templates.update",
auth(),
validate(T.TemplatesUpdateSchema),
transaction(),
async (ctx: APIContext<T.TemplatesUpdateReq>) => {
const { transaction } = ctx.state;
const { id, data, ...updatedFields } = ctx.input.body;
const { user } = ctx.state.auth;
const template = await Template.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
authorize(user, "update", template);
if (updatedFields.collectionId !== undefined) {
if (updatedFields.collectionId) {
const collection = await Collection.findByPk(
updatedFields.collectionId,
{
userId: user.id,
transaction,
}
);
authorize(user, "update", collection);
} else {
authorize(user, "createTemplate", user.team);
}
}
if (data) {
template.content = data;
}
await template.updateWithCtx(ctx, updatedFields);
ctx.body = {
data: presentTemplate(template),
policies: presentPolicies(user, [template]),
};
}
);
export default router;
+47
View File
@@ -48,6 +48,7 @@ import {
OAuthClient,
OAuthAuthentication,
Relationship,
Template,
} from "@server/models";
import { RelationshipType } from "@server/models/Relationship";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
@@ -430,6 +431,52 @@ export async function buildDocument(
return document;
}
export async function buildTemplate(
overrides: Omit<Partial<Template>, "collectionId"> & {
userId?: string;
text?: string;
collectionId?: string | null;
} = {}
) {
if (!overrides.teamId) {
const team = await buildTeam();
overrides.teamId = team.id;
}
if (!overrides.userId) {
const user = await buildUser({
teamId: overrides.teamId,
});
overrides.userId = user.id;
}
let collection;
if (overrides.collectionId === undefined) {
collection = await buildCollection({
teamId: overrides.teamId,
userId: overrides.userId,
});
overrides.collectionId = collection.id;
}
const text = overrides.text ?? "This is the text in an example template";
const template = await Template.create(
{
title: faker.lorem.words(4),
content: overrides.content ?? parser.parse(text)?.toJSON(),
lastModifiedById: overrides.userId,
createdById: overrides.userId,
editorVersion: "12.0.0",
...overrides,
},
{
silent: overrides.createdAt || overrides.updatedAt ? true : false,
}
);
return template;
}
export async function buildComment(overrides: {
userId: string;
documentId: string;
+11 -1
View File
@@ -248,6 +248,16 @@ export type DocumentEvent = BaseEvent<Document> &
| DocumentMovedEvent
);
export type TemplateEvent = BaseEvent<Document> & {
name:
| "templates.create"
| "templates.update"
| "templates.delete"
| "templates.restore";
modelId: string;
collectionId?: string;
};
export type EmptyTrashEvent = {
name: "documents.empty_trash";
teamId: string;
@@ -486,6 +496,7 @@ export type Event =
| ShareEvent
| SubscriptionEvent
| TeamEvent
| TemplateEvent
| UserEvent
| UserMembershipEvent
| ViewEvent
@@ -532,7 +543,6 @@ export type DocumentJSONExport = {
updatedAt: string;
publishedAt: string | null;
fullWidth: boolean;
template: boolean;
parentDocumentId: string | null;
};
+31 -22
View File
@@ -94,9 +94,7 @@
"Create template": "Create template",
"Open random document": "Open random document",
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
"Move to workspace": "Move to workspace",
"Move": "Move",
"Move to collection": "Move to collection",
"Move {{ documentType }}": "Move {{ documentType }}",
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
"Document archived": "Document archived",
@@ -168,6 +166,15 @@
"New workspace": "New workspace",
"Create a workspace": "Create a workspace",
"Login to workspace": "Login to workspace",
"Template deleted": "Template deleted",
"Deleting": "Deleting",
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.",
"Move to workspace": "Move to workspace",
"Template moved": "Template moved",
"Couldn't move the template, try again?": "Couldn't move the template, try again?",
"Move to collection": "Move to collection",
"Move template": "Move template",
"Print template": "Print template",
"Invite people": "Invite people",
"Invite to workspace": "Invite to workspace",
"Promote to {{ role }}": "Promote to {{ role }}",
@@ -179,6 +186,7 @@
"Debug": "Debug",
"Document": "Document",
"Documents": "Documents",
"Template": "Template",
"Recently viewed": "Recently viewed",
"Revision": "Revision",
"Navigation": "Navigation",
@@ -209,7 +217,6 @@
"Create": "Create",
"Collection deleted": "Collection deleted",
"Im sure Delete": "Im sure Delete",
"Deleting": "Deleting",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Type a command or search": "Type a command or search",
@@ -231,12 +238,6 @@
"Deleted Collection": "Deleted Collection",
"Untitled": "Untitled",
"Unpin": "Unpin",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
"Include nested documents": "Include nested documents",
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Copying": "Copying",
"Export started": "Export started",
"A link to your file will be sent through email soon": "A link to your file will be sent through email soon",
"Preparing your download": "Preparing your download",
@@ -246,13 +247,24 @@
"Include child documents": "Include child documents",
"When selected, exporting the document <em>{{documentName}}</em> may take some time.": "When selected, exporting the document <em>{{documentName}}</em> may take some time.",
"You will receive an email when it's complete.": "You will receive an email when it's complete.",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
"Include nested documents": "Include nested documents",
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Copying": "Copying",
"Search collections & documents": "Search collections & documents",
"Search collections": "Search collections",
"No results found": "No results found",
"Select a location to move": "Select a location to move",
"Document moved": "Document moved",
"Couldnt move the document, try again?": "Couldnt move the document, try again?",
"Move to <em>{{ location }}</em>": "Move to <em>{{ location }}</em>",
"Couldnt move the template, try again?": "Couldnt move the template, try again?",
"Document options": "Document options",
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
"Template": "Template",
"You updated": "You updated",
"{{ userName }} updated": "{{ userName }} updated",
"You deleted": "You deleted",
@@ -497,6 +509,8 @@
"Unstar document": "Unstar document",
"Star document": "Star document",
"Select a color": "Select a color",
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents",
"Youre editing a template": "Youre editing a template",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Enable other members to use the template immediately": "Enable other members to use the template immediately",
@@ -689,8 +703,8 @@
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "Contents",
"Table of contents": "Table of contents",
"Template options": "Template options",
"User options": "User options",
"template": "template",
"document": "document",
"Export complete": "Export complete",
"Export failed": "Export failed",
@@ -816,23 +830,15 @@
"Sorry, the last change could not be persisted please reload the page": "Sorry, the last change could not be persisted please reload the page",
"{{ count }} days": "{{ count }} day",
"{{ count }} days_plural": "{{ count }} days",
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents",
"Youre editing a template": "Youre editing a template",
"Deleted by {{userName}}": "Deleted by {{userName}}",
"Observing {{ userName }}": "Observing {{ userName }}",
"Backlinks": "Backlinks",
"This document is large which may affect performance": "This document is large which may affect performance",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested documents</em>.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Select a location to move": "Select a location to move",
"Document moved": "Document moved",
"Couldnt move the document, try again?": "Couldnt move the document, try again?",
"Move to <em>{{ location }}</em>": "Move to <em>{{ location }}</em>",
"Couldnt create the document, try again?": "Couldnt create the document, try again?",
"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.",
@@ -1162,6 +1168,8 @@
"Last accessed": "Last accessed",
"Domain": "Domain",
"Views": "Views",
"Visibility": "Visibility",
"Updated by": "Updated by",
"All roles": "All roles",
"Admins": "Admins",
"Editors": "Editors",
@@ -1312,9 +1320,10 @@
"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.",
"A template must have content": "A template must have content",
"Could not load templates": "Could not load templates",
"Templates help your team create consistent and accurate documentation.": "Templates help your team create consistent and accurate documentation.",
"No templates have been created yet": "No templates have been created yet",
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.",
"Confirmation code": "Confirmation code",