Compare commits

...

21 Commits

Author SHA1 Message Date
Tom Moor 1847baaa20 stash 2025-10-01 14:09:14 +02:00
Tom Moor e02ac64277 Merge main 2025-09-24 20:58:52 -04:00
Tom Moor fdc1d40099 wip 2025-06-27 14:38:06 +01:00
Tom Moor 608a9833fb refactor 2025-06-27 11:51:22 +01:00
Tom Moor 70b3a7ff3a wip 2025-06-18 17:31:33 +02:00
Tom Moor 78e8341a97 Save template data 2025-06-14 23:53:38 -04:00
Tom Moor 62750f63b8 refactor 2025-06-14 22:31:43 -04:00
Tom Moor b335f19736 test 2025-06-14 19:31:38 -04:00
Tom Moor 703bcfb99a Merge main 2025-06-14 18:43:37 -04:00
Tom Moor 286483f692 Merge main 2025-05-24 20:00:33 -04:00
Tom Moor 607a793290 actions 2025-03-19 23:17:55 -04:00
Tom Moor 34822d8da6 tsc 2025-03-19 09:40:22 -04:00
Tom Moor 8c77061249 api endpoint tests 2025-03-19 09:29:48 -04:00
Tom Moor c08b1a9ddb Endpoints, tests, tsc 2025-03-18 23:38:11 -04:00
Tom Moor 13565d27ec tests 2025-03-18 22:29:09 -04:00
Tom Moor cfd7996a6f wip 2025-03-17 22:10:56 -04:00
Tom Moor ce6b384580 TemplateMenu 2025-03-17 20:17:24 -04:00
Tom Moor df893b29fd Templates table 2025-03-16 18:49:52 -04:00
Tom Moor 0cb3857b70 further cleanup 2025-03-16 18:22:05 -04:00
Tom Moor e50572f791 lint 2025-03-16 15:06:09 -04:00
Tom Moor c71f1382a2 wip 2025-03-16 14:45:16 -04:00
83 changed files with 2441 additions and 1000 deletions
+28 -78
View File
@@ -3,12 +3,10 @@ import {
CollectionIcon,
EditIcon,
ExportIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
@@ -31,11 +29,7 @@ import {
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import {
newDocumentPath,
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import { searchPath } from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
@@ -349,40 +343,6 @@ export const restoreCollection = createActionV2({
},
});
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
@@ -419,47 +379,37 @@ export const exportCollection = createActionV2({
},
});
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
});
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
export const createTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
return {
pathname,
search,
state: { sidebarContext },
};
stores.dialogs.openModal({
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
+13 -94
View File
@@ -43,12 +43,12 @@ import {
import { getEventFiles } from "@shared/utils/files";
import UserMembership from "~/models/UserMembership";
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 DocumentMove from "~/components/DocumentExplorer/DocumentMove";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -121,18 +121,12 @@ export const editDocument = createInternalLinkActionV2({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const { auth, policies } = stores;
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
@@ -146,7 +140,7 @@ export const editDocument = createInternalLinkActionV2({
},
});
export const createDocument = createAction({
export const createDocument = createActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
@@ -200,12 +194,7 @@ export const createDocumentFromTemplate = createInternalLinkActionV2({
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
return false;
}
@@ -330,7 +319,7 @@ export const publishDocument = createActionV2({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -894,7 +883,7 @@ export const createTemplateFromDocument = createActionV2({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
if (!document?.isActive) {
return false;
}
return !!(
@@ -946,46 +935,8 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createActionV2({
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 = createActionV2({
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 />,
@@ -1023,8 +974,7 @@ export const moveDocument = createActionV2({
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;
@@ -1032,25 +982,6 @@ export const moveDocument = createActionV2({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionV2WithChildren({
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 = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1109,10 +1040,7 @@ export const restoreDocument = createActionV2({
: 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
@@ -1149,10 +1077,7 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
? 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;
@@ -1336,12 +1261,7 @@ export const openDocumentInsights = createActionV2({
? 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
@@ -1438,7 +1358,6 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+103
View File
@@ -0,0 +1,103 @@
import { MoveIcon, PlusIcon, 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 { TemplateNew } from "~/components/Template/TemplateNew";
import { createActionV2 } from "~/actions";
import { ActiveTemplateSection, TemplateSection } from "../sections";
export const createTemplate = createActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
perform: ({ stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: "",
content: <TemplateNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const deleteTemplate = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeTemplateId, stores }) => {
if (!activeTemplateId) {
return false;
}
return !!stores.policies.abilities(activeTemplateId).delete;
},
perform: ({ activeTemplateId, stores, t }) => {
if (activeTemplateId) {
const template = stores.templates.get(activeTemplateId);
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 moveTemplate = createActionV2({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ activeTemplateId, stores }) => {
if (!activeTemplateId) {
return false;
}
return !!stores.policies.abilities(activeTemplateId).move;
},
perform: ({ activeTemplateId, stores, t }) => {
if (activeTemplateId) {
const template = stores.templates.get(activeTemplateId);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
}
},
});
export const rootTemplateActions = [moveTemplate];
+4 -2
View File
@@ -6,16 +6,18 @@ import { rootNotificationActions } from "./definitions/notifications";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootTemplateActions } from "./definitions/templates";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDeveloperActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootNotificationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
...rootTemplateActions,
...rootUserActions,
];
+7
View File
@@ -46,6 +46,13 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
@@ -12,15 +12,15 @@ import history from "~/utils/history";
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.orderedData.map((template) =>
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -58,7 +58,7 @@ const useTemplatesAction = () => {
),
})
),
[documents.templatesAlphabetical]
[templates.orderedData]
);
const newFromTemplate = useMemo(
-1
View File
@@ -182,7 +182,6 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
}
const Content = styled.span`
background: ${s("background")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
+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 { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkActionV2({
name: collection?.name,
section: ActiveDocumentSection,
+5 -1
View File
@@ -2,11 +2,12 @@ import { action, computed, observable } from "mobx";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import Template from "~/models/Template";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
document?: Document;
document?: Document | Template;
/** The editor instance for this document */
editor?: Editor;
@@ -68,6 +69,9 @@ class DocumentContext {
}
private updateTasks() {
if (this.document instanceof Template) {
return;
}
const tasks = this.editor?.getTasks() ?? [];
const total = tasks.length ?? 0;
const completed = tasks.filter((t) => t.completed).length ?? 0;
@@ -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 { NavigationNode } from "@shared/types";
import 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 */
@@ -36,13 +36,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) {
@@ -76,34 +71,36 @@ 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 instanceof Document && (
<>
{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>"
@@ -113,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
@@ -17,8 +17,6 @@ import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -27,6 +25,8 @@ import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import flatten from "lodash/flatten";
type Props = {
@@ -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,
showDocuments,
defaultValue,
}: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -130,10 +138,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const baseDepth =
nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
) - 1;
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
@@ -209,7 +218,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)) {
@@ -249,7 +258,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
path;
if (isCollection) {
const col = collections.get(node.collectionId as string);
const col = collections.get(node.id);
renderedIcon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
@@ -4,9 +4,9 @@ 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 Flex from "~/components/Flex";
import Text from "~/components/Text";
import { Node as SearchResult } from "./DocumentExplorerNode";
type Props = {
selected: boolean;
@@ -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 { NavigationNode } from "@shared/types";
import 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;
@@ -43,21 +41,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) {
@@ -88,7 +73,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>"
@@ -102,7 +87,7 @@ function DocumentMove({ document }: Props) {
) : (
t("Select a location to move")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
@@ -111,23 +96,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,90 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { NavigationNode } from "@shared/types";
import Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, collections, policies } = useStores();
const { t } = useTranslation();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
flatten(
collections.orderedData.map((collection) => ({
...collection.asNavigationNode,
children: [],
}))
)
// Filter out collections that we don't have permission to create documents in.
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collections.orderedData]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = selectedPath.collectionId 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 -7
View File
@@ -38,7 +38,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -73,7 +72,6 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -81,7 +79,6 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -152,14 +149,11 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
{!document.isArchived && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
+1 -3
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,6 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -196,7 +194,7 @@ const DocumentMeta: React.FC<Props> = ({
</span>
)}
{timeSinceNow()}
{canShowProgressBar && (
{isTasks && (
<>
<Separator />
<DocumentTasks document={document} />
-3
View File
@@ -15,7 +15,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
@@ -27,7 +26,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments,
showCollection,
showPublished,
showTemplate,
showDraft,
...rest
}: Props) {
@@ -49,7 +47,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
+13 -1
View File
@@ -7,8 +7,20 @@ const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
z-index: -1;
}
`;
export default TeamLogo;
+33
View File
@@ -0,0 +1,33 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
import Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const { templates } = useStores();
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 { ProsemirrorData } from "@shared/types";
import 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);
};
const handleSave = (options: { autosave?: boolean }) => {
if (options.autosave) {
return;
}
template.data = dataRef.current;
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;
`;
+30
View File
@@ -0,0 +1,30 @@
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 = {
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({ onSubmit }: Props) {
const { templates } = useStores();
const [template] = useState(new Template({ title: "" }, 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} />;
});
@@ -88,7 +88,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
options={options}
value={defaultCollectionId ?? "workspace"}
onChange={handleSelection}
label={t("Location")}
label={t("Visibility")}
/>
);
};
+4 -13
View File
@@ -6,9 +6,8 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
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 { settingsPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
@@ -22,7 +21,6 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
const document = documents.get(documentId);
invariant(document, "Document must exist");
const [publish, setPublish] = React.useState(true);
const [collectionId, setCollectionId] = React.useState(
document.collectionId ?? null
);
@@ -30,13 +28,13 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
publish,
publish: true,
});
if (template) {
history.push(documentPath(template));
history.push(settingsPath("templates"));
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
}, [t, document, history, collectionId]);
return (
<ConfirmationDialog
@@ -60,13 +58,6 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
defaultCollectionId={collectionId}
onSelect={setCollectionId}
/>
<Switch
name="publish"
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={setPublish}
/>
</Flex>
</ConfirmationDialog>
);
+3 -13
View File
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
@@ -62,18 +61,9 @@ export default function useCollectionTrees(): NavigationNode[] {
return node;
};
const collectionNode: NavigationNode = {
id: collection.id,
title: collection.name,
url: collection.path,
type: NavigationNodeType.Collection,
children: collection.documents
? sortNavigationNodes(collection.documents, collection.sort, true)
: [],
parent: null,
};
return addParent(addCollectionId(addDepth(addType(collectionNode), 1)));
return addParent(
addCollectionId(addDepth(addType(collection.asNavigationNode)))
);
};
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
+2 -3
View File
@@ -19,7 +19,6 @@ import {
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory,
pinDocument,
createDocumentFromTemplate,
@@ -41,6 +40,7 @@ import usePolicy from "./usePolicy";
import useCurrentUser from "./useCurrentUser";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
import Template from "~/models/Template";
type Props = {
/** Document for which the actions are generated */
@@ -50,7 +50,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({
@@ -103,7 +103,6 @@ export function useDocumentMenuAction({
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
pinDocument,
createDocumentFromTemplate,
+7 -10
View File
@@ -14,12 +14,13 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ActionV2 } from "~/types";
import { useComputed } from "./useComputed";
import Template from "~/models/Template";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
/**
@@ -35,11 +36,11 @@ type Props = {
*/
export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { templates } = useStores();
const { t } = useTranslation();
const templateToAction = useCallback(
(template: Document): ActionV2 =>
(template: Template): ActionV2 =>
createActionV2({
name: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
@@ -62,11 +63,7 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
return [];
}
const templates = documents.templates.filter(
(template) => template.publishedAt
);
const collectionTemplatesActions = templates
const collectionTemplatesActions = templates.orderedData
.filter(
(template) =>
!template.isWorkspaceTemplate &&
@@ -74,8 +71,8 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
)
.map(templateToAction);
const workspaceTemplatesActions = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
const workspaceTemplatesActions = templates.orderedData
.filter((template) => template.isWorkspaceTemplate)
.map(templateToAction);
return [
+85
View File
@@ -0,0 +1,85 @@
import { DocumentIcon } from "outline-icons";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Template from "~/models/Template";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Template) => void;
};
/**
* This hook provides a memoized list of menu items for both collection-specific
* templates and workspace-wide templates. It filters templates based on whether
* they are published and organizes them into appropriate sections.
*
* Collection-specific templates are displayed first, followed by workspace templates
* with a separator in between (if both types exist).
*
* @returns An array of MenuItem objects representing templates that can be applied
* to the current document. Returns an empty array if no callback is provided.
*/
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
const user = useCurrentUser();
const { templates } = useStores();
const { t } = useTranslation();
const templateToMenuItem = useCallback(
(template: Template): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate?.(template),
}),
[user, onSelectTemplate]
);
const collectionItems = templates.orderedData
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates.orderedData
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
if (!onSelectTemplate) {
return [];
}
return collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
}
+1 -3
View File
@@ -28,14 +28,13 @@ import {
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import { createDocument } from "~/actions/definitions/documents";
import { ActionContextProvider } from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
@@ -202,7 +201,6 @@ function CollectionMenu({
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortAction,
exportCollection,
archiveCollection,
+3 -2
View File
@@ -18,6 +18,7 @@ import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuSeparator } from "~/components/primitives/components/Menu";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import Template from "~/models/Template";
type Props = {
/** Document for which the menu is to be shown */
@@ -32,8 +33,8 @@ type Props = {
showToggleEmbeds?: boolean;
/** 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;
/** Invoked when the "Apply template" menu item is clicked */
onSelectTemplate?: (template: Template) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
+63
View File
@@ -0,0 +1,63 @@
import { observer } from "mobx-react";
import * as React from "react";
import { DuplicateIcon, EditIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import Template from "~/models/Template";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionV2Separator, createActionV2 } from "~/actions";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useMenuAction } from "~/hooks/useMenuAction";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { deleteTemplate, moveTemplate } from "~/actions/definitions/templates";
import { ActionContextProvider } from "~/hooks/useActionContext";
type Props = {
template: Template;
onEdit?: () => void;
};
function TemplateMenu({ template, onEdit }: Props) {
const { t } = useTranslation();
const { templates } = useStores();
const can = usePolicy(template);
const section = "Template";
const actions = React.useMemo(
() => [
createActionV2({
name: `${t("Edit")}`,
visible: !!can.update,
icon: <EditIcon />,
section,
perform: () => onEdit?.(),
}),
createActionV2({
name: t("Duplicate"),
visible: !!can.duplicate,
icon: <DuplicateIcon />,
section,
perform: () => templates.duplicate(template),
}),
moveTemplate,
ActionV2Separator,
deleteTemplate,
],
[can.update, can.duplicate, onEdit]
);
const rootAction = useMenuAction(actions);
return (
<ActionContextProvider value={{ activeTemplateId: template.id }}>
<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 Document from "~/models/Document";
import 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 -1
View File
@@ -356,7 +356,9 @@ export default class Collection extends ParanoidModel {
title: this.name,
color: this.color ?? undefined,
icon: this.icon ?? undefined,
children: this.documents ?? [],
children: this.documents
? sortNavigationNodes(this.documents, this.sort, true)
: [],
url: this.url,
};
}
+4 -24
View File
@@ -25,7 +25,6 @@ import 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 Notification from "./Notification";
import Pin from "./Pin";
@@ -147,12 +146,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.
*/
@@ -267,20 +260,17 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get path(): string {
const prefix =
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
return `/doc/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `${prefix}/${slugifiedTitle}-${this.urlId}`;
return `/doc/${slugifiedTitle}-${this.urlId}`;
}
@computed
get noun(): string {
return this.template ? t("template") : t("document");
return t("document");
}
@computed
@@ -372,11 +362,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;
@@ -442,11 +427,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");
}
@@ -640,7 +620,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
+138
View File
@@ -0,0 +1,138 @@
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 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 { 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;
}
/**
* 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;
@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 isActive(): boolean {
return !this.isDeleted;
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default abstract class Model {
store: Store<Model>;
constructor(fields: Record<string, any>, store: Store<Model>) {
constructor(fields: Record<string, any> = {}, store: Store<Model>) {
this.store = store;
this.updateData(fields);
this.isNew = !this.id;
+6 -14
View File
@@ -1,13 +1,12 @@
import { RouteComponentProps, Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import { Switch } from "react-router-dom";
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";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const Document = lazy(() => import("~/scenes/Document"));
const Template = lazy(() => import("~/scenes/Settings/Template"));
export default function SettingsRoutes() {
const configs = useSettingsConfig();
@@ -25,20 +24,13 @@ export default 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}
/>
<Route
exact
path={`${settingsPath("templates")}/new`}
component={(props: RouteComponentProps) => (
<DocumentNew {...props} template />
)}
path={settingsPath("templates", ":id")}
component={Template}
/>
<Route component={Error404} />
</Switch>
-1
View File
@@ -22,7 +22,6 @@ function Archive() {
<Empty>{t("The document archive is empty at the moment.")}</Empty>
}
showCollection
showTemplate
/>
</Scene>
);
@@ -169,7 +169,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 (!can.update && isEditRoute && !document.template) {
if (!can.update && isEditRoute) {
history.push(document.url);
return;
}
+6 -5
View File
@@ -33,10 +33,11 @@ import { isModKey } from "@shared/utils/keyboard";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Template from "~/models/Template";
import ConnectionStatus from "~/scenes/Document/components/ConnectionStatus";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPublish from "~/scenes/DocumentPublish";
import Branding from "~/components/Branding";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
import ErrorBoundary from "~/components/ErrorBoundary";
import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
@@ -154,7 +155,7 @@ class DocumentScene extends React.Component<Props> {
* @param selection The selection to replace, if any
*/
replaceSelection = (
template: Document | Revision,
template: Template | Revision,
selection?: TextSelection | AllSelection
) => {
const editorRef = this.editor.current;
@@ -179,7 +180,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;
}
@@ -456,7 +457,7 @@ class DocumentScene extends React.Component<Props> {
tocPos &&
(isShare
? ui.tocVisible !== false
: !document.isTemplate && ui.tocVisible === true);
: !(document instanceof Template) && ui.tocVisible === true);
const tocOffset =
tocPos === TOCPosition.Left
? EditorStyleHelper.tocWidth / -2
@@ -587,7 +588,7 @@ class DocumentScene extends React.Component<Props> {
multiplayer={multiplayerEditor}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
template={document instanceof Template}
document={document}
value={readOnly ? document.data : undefined}
defaultValue={document.data}
@@ -62,10 +62,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
</CommentLink>
</>
)}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
{totalViewers && can.listViews && !document.isDraft ? (
<Wrapper>
<Separator />
<InsightsButton action={openDocumentInsights}>
+6 -5
View File
@@ -10,6 +10,7 @@ import { TeamPreference } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import Template from "~/models/Template";
import { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Editor, { Props as EditorProps } from "~/components/Editor";
@@ -40,7 +41,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: {
@@ -48,7 +49,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
autosave?: boolean;
publish?: boolean;
}) => void;
children: React.ReactNode;
children?: React.ReactNode;
};
/**
@@ -210,7 +211,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
{t("Last updated")} <Time dateTime={document.updatedAt} addSuffix />
</SharedMeta>
) : null
) : (
) : document instanceof Document ? (
<DocumentMeta
document={document}
to={
@@ -226,7 +227,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
}
rtl={direction === "rtl"}
/>
)}
) : null}
<EditorComponent
ref={mergeRefs([ref, handleRefChanged])}
autoFocus={!!document.title && !props.defaultValue}
@@ -252,7 +253,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
editorStyle={editorStyle}
{...rest}
/>
<div ref={childRef}>{children}</div>
{children && <div ref={childRef}>{children}</div>}
</Flex>
);
}
+18 -30
View File
@@ -10,6 +10,7 @@ import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Template from "~/models/Template";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
@@ -21,7 +22,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";
@@ -52,7 +52,7 @@ type Props = {
isPublishing: boolean;
publishingIsDisabled: boolean;
savingIsDisabled: boolean;
onSelectTemplate: (template: Document) => void;
onSelectTemplate: (template: Template) => void;
onSave: (options: {
done?: boolean;
publish?: boolean;
@@ -109,12 +109,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
@@ -219,11 +217,7 @@ function DocumentHeader({
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
{toc} <Star document={document} color={theme.textSecondary} />
</DocumentBreadcrumb>
)
}
@@ -249,24 +243,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}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplateEditable) && (
{isEditing && (
<Action>
<Tooltip
content={t("Save")}
@@ -274,8 +265,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
action={isTemplate ? navigateToTemplateSettings : undefined}
onClick={isTemplate ? undefined : handleSave}
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
hideIcon
@@ -316,9 +306,7 @@ function DocumentHeader({
hideOnActionDisabled
hideIcon
>
{document.collectionId || document.isWorkspaceTemplate
? t("Publish")
: `${t("Publish")}`}
{document.collectionId ? 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 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 -24
View File
@@ -8,11 +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,
settingsPath,
} from "~/utils/routeHelpers";
import { collectionPath, documentPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -25,8 +21,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;
@@ -55,12 +50,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")
: collectionPath(collection?.path || "/");
history.push(path);
history.push(collectionPath(collection?.path || "/"));
}
onSubmit();
@@ -93,17 +83,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();
@@ -48,7 +43,6 @@ function DocumentNew({ template }: Props) {
parentDocument?.fullWidth ||
user.getPreference(UserPreference.FullWidthDocuments),
templateId: query.get("templateId") ?? undefined,
template,
title: query.get("title") ?? "",
data: ProsemirrorHelper.getEmptyDocument(),
},
@@ -56,7 +50,7 @@ function DocumentNew({ template }: Props) {
);
history.replace(
template || !user.separateEditMode
!user.separateEditMode
? documentPath(document)
: documentEditPath(document),
location.state
-1
View File
@@ -318,7 +318,6 @@ function Search() {
highlight={query}
context={result.context}
showCollection
showTemplate
/>
))
: null
+95
View File
@@ -0,0 +1,95 @@
import { observer } from "mobx-react";
import { CopyIcon, InternetIcon, ReplaceIcon, ShapesIcon } from "outline-icons";
import { useEffect, useCallback, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContentEditable from "~/components/ContentEditable";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import { FormData } from "~/components/OAuthClient/OAuthClientForm";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Tooltip from "~/components/Tooltip";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import OAuthClientMenu from "~/menus/OAuthClientMenu";
import isCloudHosted from "~/utils/isCloudHosted";
import { settingsPath } from "~/utils/routeHelpers";
import { ActionRow } from "./components/ActionRow";
import { CopyButton } from "./components/CopyButton";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
import { createInternalLinkActionV2 } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import Template from "~/models/Template";
import TemplateMenu from "~/menus/TemplateMenu";
import { TemplateForm } from "~/components/Template/TemplateForm";
import { TemplateNew } from "~/components/Template/TemplateNew";
import history from "~/utils/history";
import { TemplateEdit } from "~/components/Template/TemplateEdit";
type Props = {
template: Template;
};
const LoadingState = observer(function LoadingState() {
const { id } = useParams<{ id: string }>();
const { templates } = useStores();
const template = templates.get(id);
const { request } = useRequest(() => templates.fetch(id));
useEffect(() => {
if (!template) {
void request();
}
}, [template]);
if (!template) {
return <LoadingIndicator />;
}
return <TemplateSetting template={template} />;
});
const TemplateSetting = observer(function Template_({ template }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const breadcrumbActions = useMemo(
() => [
createInternalLinkActionV2({
name: t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
to: settingsPath("templates"),
}),
],
[t]
);
const handleSave = useCallback(() => {
history.push(settingsPath("templates"));
}, []);
return (
<Scene
title={template.title}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={<TemplateMenu template={template} />}
>
{template ? <TemplateEdit template={template} onSubmit={handleSave} /> : <TemplateNew onSubmit={handleSave} />}
</Scene>
);
});
export default LoadingState;
+135 -46
View File
@@ -1,75 +1,164 @@
import { 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 { PlusIcon, ShapesIcon } from "outline-icons";
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 Template from "~/models/Template";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
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 { createTemplate } from "~/actions/definitions/templates";
import useActionContext from "~/hooks/useActionContext";
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 context = useActionContext();
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.orderedData, reqParams.query),
sort,
reqFn: templates.fetchPage,
reqParams,
});
const isEmpty = !loading && !templates.orderedData.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>
<Button
type="button"
action={createTemplate}
icon={<PlusIcon />}
context={context}
>
{`${t("New template")}`}
</Button>
</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>
);
}
@@ -0,0 +1,146 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import React 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 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 {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { TemplateEdit } from "~/components/Template/TemplateEdit";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import TemplateMenu from "~/menus/TemplateMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
import history from "~/utils/history";
import { settingsPath } from "~/utils/routeHelpers";
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Template>, "columns" | "rowHeight">;
export function TemplatesTable(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const { dialogs } = useStores();
const handleOpen = (template: Template) => () => {
history.push(settingsPath("templates", template.id));
};
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}
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: "1fr",
},
{
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}
{...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
View File
@@ -33,7 +33,6 @@ function Trash() {
heading={<Subheading sticky>{t("Recently deleted")}</Subheading>}
empty={<Empty>{t("Trash is empty at the moment.")}</Empty>}
showCollection
showTemplate
/>
</Scene>
);
+1 -40
View File
@@ -71,10 +71,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
@@ -91,18 +88,6 @@ export default class DocumentsStore extends Store<Document> {
return orderBy(this.all, "updatedAt", "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),
@@ -145,21 +130,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,
@@ -224,11 +194,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;
@@ -481,10 +446,6 @@ export default class DocumentsStore extends Store<Document> {
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,
+3
View File
@@ -27,6 +27,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";
@@ -63,6 +64,7 @@ export default class RootStore {
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
templates: TemplatesStore;
users: UsersStore;
views: ViewsStore;
fileOperations: FileOperationsStore;
@@ -93,6 +95,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);
+56
View File
@@ -0,0 +1,56 @@
import find from "lodash/find";
import orderBy from "lodash/orderBy";
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 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");
}
@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);
};
getByUrl = (url = ""): Template | undefined =>
find(
this.orderedData,
(template) => url.endsWith(template.urlId) || url.endsWith(template.id)
);
@computed
get active(): Template | undefined {
return this.rootStore.ui.activeDocumentId
? this.data.get(this.rootStore.ui.activeDocumentId)
: undefined;
}
@computed
get orderedData(): Template[] {
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
}
}
+2
View File
@@ -96,7 +96,9 @@ export type ActionContext = {
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
// TODO: Refactor this to data structure of active models
activeCollectionId?: string | undefined;
activeTemplateId?: string | undefined;
activeDocumentId: string | undefined;
currentUserId: string | undefined;
currentTeamId: string | undefined;
-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(),
+1 -7
View File
@@ -114,6 +114,7 @@ type AdditionalFindOptions = {
[Op.is]: null,
},
},
template: false,
},
attributes: {
include: [stateIfContentEmpty],
@@ -857,13 +858,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.
*
+290
View File
@@ -0,0 +1,290 @@
import { isUUID } from "class-validator";
import {
Identifier,
InferAttributes,
InferCreationAttributes,
NonNullFindOptions,
Op,
FindOptions,
EmptyResultError,
} from "sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
Length as SimpleLength,
DefaultScope,
Default,
HasMany,
Unique,
Scopes,
BeforeValidate,
} from "sequelize-typescript";
import slugify from "slugify";
import { 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 IdModel from "./base/IdModel";
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: {
archivedAt: {
[Op.eq]: null,
},
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 IdModel<
InferAttributes<Template>,
Partial<InferCreationAttributes<Template>>
> {
@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;
/** The version of the editor last used to edit this document. */
@SimpleLength({
max: 255,
msg: `editorVersion must be 255 characters or less`,
})
@Column
editorVersion: string;
/** 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 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[];
@Default(true)
@Column
template: boolean;
// 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;
}
/**
* Convenience method that returns whether this template is deleted.
*
* @returns boolean
*/
get isDeleted(): boolean {
return !!this.deletedAt;
}
@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;
+5 -5
View File
@@ -10,7 +10,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 { Collection, Document, Revision } from "@server/models";
import { Collection, Document, Revision, Template } from "@server/models";
import diff from "@server/utils/diff";
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
import { TextHelper } from "./TextHelper";
@@ -73,7 +73,7 @@ export class DocumentHelper {
* @returns The document content as a plain JSON object
*/
static async toJSON(
document: Document | Revision | Collection,
document: Document | Template | Revision | Collection,
options?: {
/** The team context */
teamId?: string;
@@ -85,7 +85,7 @@ export class DocumentHelper {
internalUrlBase?: string;
}
): Promise<ProsemirrorData> {
let doc: Node | null;
let doc: Node | null | undefined;
let data;
if ("content" in document && document.content) {
@@ -104,8 +104,8 @@ export class DocumentHelper {
doc = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
} else if (document instanceof Collection) {
doc = parser.parse(document.description ?? "");
} else {
doc = parser.parse(document.text ?? "");
} else if (document instanceof Document) {
doc = parser.parse(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";
+23 -78
View File
@@ -15,23 +15,23 @@ allow(User, "createDocument", Team, (actor, document) =>
)
);
allow(User, "read", Document, (actor, document) =>
and(
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.Read,
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(!!document?.isDraft, actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "readTemplate", actor.team)
),
can(actor, "readDocument", document?.collection)
allow(
User,
["read", "star", "unstar", "subscribe", "unsubscribe"],
Document,
(actor, document) =>
and(
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.Read,
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
!!document?.isDraft && actor.id === document?.createdById,
can(actor, "readDocument", document?.collection)
)
)
)
);
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
@@ -60,29 +60,15 @@ allow(User, "comment", Document, (actor, document) =>
),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
or(!document?.collection, document?.collection?.commenting !== false)
)
);
allow(
User,
["star", "unstar", "subscribe", "unsubscribe"],
Document,
(actor, document) =>
and(
//
can(actor, "read", document),
!document?.template
)
);
allow(User, "share", Document, (actor, document) =>
and(
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
or(!document?.collection, can(actor, "share", document?.collection))
)
);
@@ -99,14 +85,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)
)
)
!!document?.isDraft && actor.id === document?.createdById
)
)
)
@@ -122,7 +101,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]),
@@ -140,14 +118,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
)
)
);
@@ -158,20 +129,13 @@ allow(User, "move", Document, (actor, document) =>
or(
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)
)
)
);
allow(User, "createChildDocument", Document, (actor, document) =>
and(can(actor, "update", document), !document?.isDraft, !document?.template)
and(can(actor, "update", document), !document?.isDraft)
);
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
@@ -179,7 +143,6 @@ allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
can(actor, "update", document),
can(actor, "update", document?.collection),
!document?.isDraft,
!document?.template,
!actor.isGuest
)
);
@@ -190,7 +153,6 @@ allow(User, "pinToHome", Document, (actor, document) =>
isTeamAdmin(actor, document),
isTeamMutable(actor),
!document?.isDraft,
!document?.template,
!!document?.isActive
)
);
@@ -200,11 +162,7 @@ allow(User, "delete", Document, (actor, document) =>
isTeamModel(actor, document),
isTeamMutable(actor),
!document?.isDeleted,
or(
can(actor, "unarchive", document),
can(actor, "update", document),
and(!document?.isWorkspaceTemplate, !document?.collection)
)
or(can(actor, "unarchive", document), can(actor, "update", document))
)
);
@@ -219,11 +177,7 @@ allow(User, ["restore", "permanentDelete"], 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)
),
!!document?.isDraft && actor.id === document?.createdById,
!document?.collection
)
)
@@ -231,7 +185,6 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
allow(User, "archive", Document, (actor, document) =>
and(
!document?.template,
!document?.isDraft,
!!document?.isActive,
can(actor, "update", document),
@@ -245,7 +198,6 @@ allow(User, "archive", Document, (actor, document) =>
allow(User, "unarchive", Document, (actor, document) =>
and(
!document?.template,
!document?.isDraft,
!document?.isDeleted,
!!document?.archivedAt,
@@ -256,7 +208,7 @@ allow(User, "unarchive", Document, (actor, document) =>
DocumentPermission.Admin,
]),
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById)
!!document?.isDraft && actor.id === document?.createdById
)
)
);
@@ -279,13 +231,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?"
+2 -2
View File
@@ -1,4 +1,4 @@
import { buildUser, buildTeam } from "@server/test/factories";
import { buildUser, buildAdmin, buildTeam } from "@server/test/factories";
import { serialize } from "./index";
it("should serialize policy", async () => {
@@ -10,7 +10,7 @@ it("should serialize policy", async () => {
it("should serialize domain policies on Team", async () => {
const team = await buildTeam();
const user = await buildUser({
const user = await buildAdmin({
teamId: team.id,
});
const response = serialize(user, team);
+1
View File
@@ -23,6 +23,7 @@ import "./star";
import "./subscription";
import "./user";
import "./team";
import "./template";
import "./group";
import "./webhookSubscription";
import "./userMembership";
+2 -23
View File
@@ -16,7 +16,7 @@ describe("policies/team", () => {
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toBeTruthy();
expect(abilities.createCollection).toBeTruthy();
expect(abilities.createTemplate).toBeTruthy();
expect(abilities.createTemplate).toEqual(false);
expect(abilities.createGroup).toEqual(false);
expect(abilities.createIntegration).toEqual(false);
});
@@ -53,31 +53,10 @@ describe("policies/team", () => {
expect(abilities.createIntegration).toBeTruthy();
});
describe("read template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Viewer, true],
[UserRole.Guest, true],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.readTemplate).toEqual(permission);
});
}
});
describe("create template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Member, false],
[UserRole.Viewer, false],
[UserRole.Guest, false],
]);
+1 -27
View File
@@ -1,13 +1,6 @@
import { Team, User } from "@server/models";
import { allow } from "./cancan";
import {
and,
isCloudHosted,
isTeamAdmin,
isTeamModel,
isTeamMutable,
or,
} from "./utils";
import { and, isCloudHosted, isTeamAdmin, isTeamModel, or } from "./utils";
allow(User, ["read", "readTemplate"], Team, isTeamModel);
@@ -39,22 +32,3 @@ allow(User, ["delete", "audit"], Team, (actor, team) =>
isTeamAdmin(actor, team)
)
);
allow(User, "createTemplate", Team, (actor, team) =>
and(
//
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
allow(User, "updateTemplate", Team, (actor, team) =>
and(
//
actor.isAdmin,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
+51
View File
@@ -0,0 +1,51 @@
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
)
);
+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";
@@ -59,6 +60,7 @@ export {
presentStar,
presentSubscription,
presentTeam,
presentTemplate,
presentUser,
presentView,
};
+23
View File
@@ -0,0 +1,23 @@
import { 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,
fullWidth: template.fullWidth,
collectionId: template.collectionId,
};
}
export default presentTemplate;
@@ -911,8 +911,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}`);
}
+11 -208
View File
@@ -32,6 +32,7 @@ import {
buildTeam,
buildGroup,
buildAdmin,
buildTemplate,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
@@ -1984,8 +1985,9 @@ describe("#documents.templatize", () => {
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a published workspace template", async () => {
const user = await buildUser();
const user = await buildAdmin();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
@@ -2007,6 +2009,7 @@ describe("#documents.templatize", () => {
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toBeNull();
});
it("should create a draft non-workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
@@ -2032,7 +2035,7 @@ describe("#documents.templatize", () => {
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a draft workspace template", async () => {
const user = await buildUser();
const user = await buildAdmin();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
@@ -2054,6 +2057,7 @@ describe("#documents.templatize", () => {
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toBeNull();
});
it("should create a template in a different collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
@@ -2620,56 +2624,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,
});
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,
});
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);
@@ -2987,92 +2941,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 });
@@ -3353,11 +3221,10 @@ describe("#documents.create", () => {
});
it("should retain template variables when a template is created from another template", async () => {
const user = await buildUser();
const template = await buildDocument({
const user = await buildAdmin();
const template = await buildTemplate({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "Created by user {author} on {date}",
});
@@ -3371,7 +3238,6 @@ 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 create a document with empty title if no title is explicitly passed", async () => {
@@ -3389,10 +3255,9 @@ describe("#documents.create", () => {
it("should use template title when doc is supposed to be created using the 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",
});
@@ -3405,15 +3270,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", {
@@ -3570,7 +3433,7 @@ describe("#documents.create", () => {
it("should allow creating a draft template without a collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const user = await buildAdmin({ teamId: team.id });
const res = await server.post("/api/documents.create", {
body: {
template: true,
@@ -3806,39 +3669,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 });
@@ -4456,33 +4286,6 @@ describe("#documents.delete", () => {
expect(body.success).toEqual(true);
});
it("should allow deleting document 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 buildDocument({
userId: user.id,
collectionId: collection.id,
teamId: team.id,
});
// delete collection without hooks to trigger document deletion
await collection.destroy({
hooks: false,
});
const res = await server.post("/api/documents.delete", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
it("should require authentication", async () => {
const document = await buildDocument();
const res = await server.post("/api/documents.delete", {
+5 -36
View File
@@ -56,7 +56,7 @@ 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,
@@ -90,7 +90,6 @@ router.post(
const {
sort,
direction,
template,
collectionId,
backlinkDocumentId,
parentDocumentId,
@@ -118,12 +117,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) {
@@ -153,12 +146,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,
});
}
@@ -894,8 +882,7 @@ 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"
);
@@ -910,19 +897,7 @@ router.post(
});
}
if (document.deletedAt && document.isWorkspaceTemplate) {
authorize(user, "restore", document);
await document.restore({ transaction });
await Event.createFromContext(ctx, {
name: "documents.restore",
documentId: document.id,
collectionId: document.collectionId,
data: {
title: document.title,
},
});
} else if (document.deletedAt) {
if (document.deletedAt) {
authorize(user, "restore", document);
authorize(user, "updateDocument", destCollection);
@@ -1278,7 +1253,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"
@@ -1298,8 +1273,6 @@ router.post(
}
);
authorize(user, "createChildDocument", parentDocument, { collection });
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
} else {
authorize(user, "createDocument", collection);
}
@@ -1348,8 +1321,6 @@ router.post(
if (collection) {
authorize(user, "updateDocument", collection);
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
}
if (parentDocumentId) {
@@ -1408,8 +1379,6 @@ router.post(
transaction,
});
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
} else if (!parentDocumentId) {
throw InvalidRequestError("collectionId is required to move a document");
}
-3
View File
@@ -87,9 +87,6 @@ export const DocumentsListSchema = BaseSchema.extend({
/** Id of the parent document to which the document belongs */
parentDocumentId: z.string().uuid().nullish(),
/** Boolean which denotes whether the document is a template */
template: z.boolean().optional(),
/** Document statuses to include in results */
statusFilter: z.nativeEnum(StatusFilter).array().optional(),
}),
+2
View File
@@ -41,6 +41,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";
@@ -93,6 +94,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";
+87
View File
@@ -0,0 +1,87 @@
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"].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(),
}),
});
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().nullish(),
}),
});
export type TemplatesUpdateReq = z.infer<typeof TemplatesUpdateSchema>;
@@ -0,0 +1,279 @@
import {
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 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 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);
});
});
+252
View File
@@ -0,0 +1,252 @@
import Router from "koa-router";
import { Op, WhereOptions } from "sequelize";
import documentCreator from "@server/commands/documentCreator";
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 { 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);
}
const template = (await documentCreator({
id,
title,
icon,
color,
content: data,
collectionId: collection?.id,
publish: true,
template: true,
user,
editorVersion,
ctx,
})) as unknown as Template;
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: [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.duplicate",
auth(),
validate(T.TemplatesDuplicateSchema),
transaction(),
async (ctx: APIContext<T.TemplatesDuplicateReq>) => {
const { transaction } = ctx.state;
const { id, title } = ctx.input.body;
const { user } = ctx.state.auth;
const original = await Template.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
authorize(user, "duplicate", original);
let template = await Template.createWithCtx(ctx, {
title: title ?? original.title,
createdById: user.id,
lastModifiedById: user.id,
teamId: user.teamId,
collectionId: original.collectionId,
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 (data) {
template.content = data;
}
await template.updateWithCtx(ctx, updatedFields);
ctx.body = {
data: presentTemplate(template),
policies: presentPolicies(user, [template]),
};
}
);
export default router;
+2 -1
View File
@@ -195,7 +195,8 @@ export const renderShare = async (ctx: Context, next: Next) => {
}
// Allow shares to be embedded in iframes on other websites unless prevented by team preference
const preventEmbedding = team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false;
const preventEmbedding =
team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false;
if (!preventEmbedding) {
ctx.remove("X-Frame-Options");
}
+2 -2
View File
@@ -93,9 +93,9 @@ export function createDatabaseInstance(
sequelizeStrictAttributes(instance);
return instance;
} catch (_err) {
} catch (error) {
Logger.fatal(
"Could not connect to database",
env.isDevelopment ? error.message : "Could not connect to database",
typeof databaseConfig === "string"
? new Error(
`Failed to parse: "${databaseConfig}". Ensure special characters in database URL are encoded`
+56 -1
View File
@@ -42,6 +42,7 @@ import {
SearchQuery,
Pin,
Comment,
Template,
Import,
OAuthAuthorizationCode,
OAuthClient,
@@ -386,7 +387,9 @@ export async function buildDocument(
}
if (!overrides.userId) {
const user = await buildUser();
const user = await buildUser({
teamId: overrides.teamId,
});
overrides.userId = user.id;
}
@@ -429,6 +432,58 @@ export async function buildDocument(
return document;
}
export async function buildTemplate(
// Omission first, addition later?
// This is actually a workaround to allow
// passing collectionId as null. Ideally, it
// should be updated in the Document model itself
// but that'd cascade and require further changes
// beyond the scope of what's required now
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;
+22 -21
View File
@@ -26,11 +26,9 @@
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
"Restore": "Restore",
"Collection restored": "Collection restored",
"Delete collection": "Delete collection",
"Export": "Export",
"Export collection": "Export collection",
"New document": "New document",
"New template": "New template",
"Delete collection": "Delete collection",
"Delete comment": "Delete comment",
"Mark as resolved": "Mark as resolved",
"Thread resolved": "Thread resolved",
@@ -47,6 +45,7 @@
"Debug logging disabled": "Debug logging disabled",
"Development": "Development",
"Open document": "Open document",
"New document": "New document",
"New draft": "New draft",
"New from template": "New from template",
"New nested document": "New nested document",
@@ -88,9 +87,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",
@@ -151,6 +148,11 @@
"New workspace": "New workspace",
"Create a workspace": "Create a workspace",
"Login to workspace": "Login to workspace",
"New template": "New template",
"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 template": "Move template",
"Invite people": "Invite people",
"Invite to workspace": "Invite to workspace",
"Promote to {{ role }}": "Promote to {{ role }}",
@@ -170,6 +172,7 @@
"People": "People",
"Share": "Share",
"Workspace": "Workspace",
"Template": "Template",
"Recent searches": "Recent searches",
"currently editing": "currently editing",
"currently viewing": "currently viewing",
@@ -190,7 +193,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",
@@ -224,11 +226,16 @@
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Search collections & documents": "Search collections & documents",
"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>",
"Template moved": "Template moved",
"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",
@@ -434,10 +441,11 @@
"Installation": "Installation",
"Unstar document": "Unstar document",
"Star document": "Star document",
"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",
"Location": "Location",
"Visibility": "Visibility",
"Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.",
"Editors can create, edit, and comment on documents.": "Editors can create, edit, and comment on documents.",
"Viewers can only view and comment on documents.": "Viewers can only view and comment on documents.",
@@ -604,6 +612,7 @@
"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",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
@@ -613,7 +622,6 @@
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"User options": "User options",
"template": "template",
"document": "document",
"published": "published",
"edited": "edited",
@@ -725,23 +733,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.",
@@ -1031,6 +1031,7 @@
"Last accessed": "Last accessed",
"Domain": "Domain",
"Views": "Views",
"Updated by": "Updated by",
"All roles": "All roles",
"Editors": "Editors",
"All status": "All status",
@@ -1167,9 +1168,9 @@
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Alphabetical": "Alphabetical",
"There are no templates just yet.": "There are no templates just yet.",
"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",