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