mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1847baaa20 | |||
| e02ac64277 | |||
| fdc1d40099 | |||
| 608a9833fb | |||
| 70b3a7ff3a | |||
| 78e8341a97 | |||
| 62750f63b8 | |||
| b335f19736 | |||
| 703bcfb99a | |||
| 286483f692 | |||
| 607a793290 | |||
| 34822d8da6 | |||
| 8c77061249 | |||
| c08b1a9ddb | |||
| 13565d27ec | |||
| cfd7996a6f | |||
| ce6b384580 | |||
| df893b29fd | |||
| 0cb3857b70 | |||
| e50572f791 | |||
| c71f1382a2 |
@@ -3,12 +3,10 @@ import {
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
SubscribeIcon,
|
||||
TrashIcon,
|
||||
@@ -31,11 +29,7 @@ import {
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
@@ -349,40 +343,6 @@ export const restoreCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDeleteDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const exportCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
analyticsName: "Export collection",
|
||||
@@ -419,47 +379,37 @@ export const exportCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
export const deleteCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
});
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const createTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDeleteDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ import {
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -121,18 +121,12 @@ export const editDocument = createInternalLinkActionV2({
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const { auth, policies } = stores;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
return !!can?.update && !!auth.user?.separateEditMode;
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -146,7 +140,7 @@ export const editDocument = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
export const createDocument = createActionV2({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -200,12 +194,7 @@ export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -330,7 +319,7 @@ export const publishDocument = createActionV2({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId || document?.template) {
|
||||
if (document?.collectionId) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -894,7 +883,7 @@ export const createTemplateFromDocument = createActionV2({
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
if (!document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
@@ -946,46 +935,8 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createActionV2({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.move({
|
||||
collectionId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document?.template && document?.collectionId
|
||||
? t("Move to collection")
|
||||
: t("Move");
|
||||
},
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
@@ -1023,8 +974,7 @@ export const moveDocument = createActionV2({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the button if this is a non-workspace template.
|
||||
if (!document || (document.template && !document.isWorkspaceTemplate)) {
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
@@ -1032,25 +982,6 @@ export const moveDocument = createActionV2({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the menu if this is not a template (or) a workspace template.
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
@@ -1109,10 +1040,7 @@ export const restoreDocument = createActionV2({
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
return !!collection?.isActive && !!(can.restore || can.unarchive);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1149,10 +1077,7 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
return !collection?.isActive && !!(can.restore || can.unarchive);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
@@ -1336,12 +1261,7 @@ export const openDocumentInsights = createActionV2({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.listViews &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
return !!activeDocumentId && can.listViews && !document?.isDeleted;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1438,7 +1358,6 @@ export const rootDocumentActions = [
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { MoveIcon, PlusIcon, TrashIcon } from "outline-icons";
|
||||
import { Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
|
||||
import { TemplateNew } from "~/components/Template/TemplateNew";
|
||||
import { createActionV2 } from "~/actions";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
|
||||
export const createTemplate = createActionV2({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: TemplateSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!stores.policies.abilities(currentTeamId!).createTemplate,
|
||||
perform: ({ stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: "",
|
||||
content: <TemplateNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteTemplate = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeTemplateId, stores }) => {
|
||||
if (!activeTemplateId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeTemplateId).delete;
|
||||
},
|
||||
perform: ({ activeTemplateId, stores, t }) => {
|
||||
if (activeTemplateId) {
|
||||
const template = stores.templates.get(activeTemplateId);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: t("template"),
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
|
||||
values={{
|
||||
templateName: template.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionV2({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeTemplateId, stores }) => {
|
||||
if (!activeTemplateId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeTemplateId).move;
|
||||
},
|
||||
perform: ({ activeTemplateId, stores, t }) => {
|
||||
if (activeTemplateId) {
|
||||
const template = stores.templates.get(activeTemplateId);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move template"),
|
||||
content: <TemplateMove template={template} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootTemplateActions = [moveTemplate];
|
||||
+4
-2
@@ -6,16 +6,18 @@ import { rootNotificationActions } from "./definitions/notifications";
|
||||
import { rootRevisionActions } from "./definitions/revisions";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootTeamActions } from "./definitions/teams";
|
||||
import { rootTemplateActions } from "./definitions/templates";
|
||||
import { rootUserActions } from "./definitions/users";
|
||||
|
||||
export default [
|
||||
...rootCollectionActions,
|
||||
...rootDeveloperActions,
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootNotificationActions,
|
||||
...rootRevisionActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
...rootTeamActions,
|
||||
...rootTemplateActions,
|
||||
...rootUserActions,
|
||||
];
|
||||
|
||||
@@ -46,6 +46,13 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const TemplateSection = ({ t }: ActionContext) => t("Template");
|
||||
|
||||
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
|
||||
const activeTemplate = stores.templates.active;
|
||||
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
|
||||
|
||||
@@ -12,15 +12,15 @@ import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
const { templates } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
void templates.fetchAll();
|
||||
}, [templates]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
templates.orderedData.map((template) =>
|
||||
createAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
@@ -58,7 +58,7 @@ const useTemplatesAction = () => {
|
||||
),
|
||||
})
|
||||
),
|
||||
[documents.templatesAlphabetical]
|
||||
[templates.orderedData]
|
||||
);
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
|
||||
@@ -182,7 +182,6 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
}
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${s("background")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
outline: none;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { archivePath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -2,11 +2,12 @@ import { action, computed, observable } from "mobx";
|
||||
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Template from "~/models/Template";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
class DocumentContext {
|
||||
/** The current document */
|
||||
document?: Document;
|
||||
document?: Document | Template;
|
||||
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
@@ -68,6 +69,9 @@ class DocumentContext {
|
||||
}
|
||||
|
||||
private updateTasks() {
|
||||
if (this.document instanceof Template) {
|
||||
return;
|
||||
}
|
||||
const tasks = this.editor?.getTasks() ?? [];
|
||||
const total = tasks.length ?? 0;
|
||||
const completed = tasks.filter((t) => t.completed).length ?? 0;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "../Flex";
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
+35
-38
@@ -5,13 +5,13 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
@@ -36,13 +36,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
}, [policies, collectionTrees]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -76,34 +71,36 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
{!document.isTemplate && (
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
)}
|
||||
<OptionsContainer>
|
||||
{document instanceof Document && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
@@ -113,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
</Button>
|
||||
+18
-9
@@ -17,8 +17,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
@@ -27,6 +25,8 @@ import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
import DocumentExplorerNode from "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import flatten from "lodash/flatten";
|
||||
|
||||
type Props = {
|
||||
@@ -38,9 +38,17 @@ type Props = {
|
||||
items: NavigationNode[];
|
||||
/** Automatically expand to and select item with the given id */
|
||||
defaultValue?: string;
|
||||
/** Whether to show child documents */
|
||||
showDocuments?: boolean;
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
items,
|
||||
showDocuments,
|
||||
defaultValue,
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -130,10 +138,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const baseDepth =
|
||||
nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
) - 1;
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
@@ -209,7 +218,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -249,7 +258,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
const col = collections.get(node.id);
|
||||
renderedIcon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
+1
-1
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { Node as SearchResult } from "./DocumentExplorerNode";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -43,21 +41,8 @@ function DocumentMove({ document }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
// If the document we're moving is a template, only show collections as
|
||||
// move targets.
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [
|
||||
policies,
|
||||
collectionTrees,
|
||||
document.id,
|
||||
document.parentDocumentId,
|
||||
document.isTemplate,
|
||||
]);
|
||||
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -88,7 +73,7 @@ function DocumentMove({ document }: Props) {
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
@@ -102,7 +87,7 @@ function DocumentMove({ document }: Props) {
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</StyledText>
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
@@ -111,23 +96,4 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
export const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMove);
|
||||
@@ -0,0 +1,90 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
};
|
||||
|
||||
function TemplateMove({ template }: Props) {
|
||||
const { dialogs, collections, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
flatten(
|
||||
collections.orderedData.map((collection) => ({
|
||||
...collection.asNavigationNode,
|
||||
children: [],
|
||||
}))
|
||||
)
|
||||
// Filter out collections that we don't have permission to create documents in.
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
),
|
||||
[policies, collections.orderedData]
|
||||
);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("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;
|
||||
@@ -38,7 +38,6 @@ type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
@@ -73,7 +72,6 @@ function DocumentListItem(
|
||||
showCollection,
|
||||
showPublished,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -81,7 +79,6 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
@@ -152,14 +149,11 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
{!document.isArchived && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
isTasks,
|
||||
isTemplate,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -142,7 +141,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
@@ -196,7 +194,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{canShowProgressBar && (
|
||||
{isTasks && (
|
||||
<>
|
||||
<Separator />
|
||||
<DocumentTasks document={document} />
|
||||
|
||||
@@ -15,7 +15,6 @@ type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
@@ -27,7 +26,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
showParentDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showTemplate,
|
||||
showDraft,
|
||||
...rest
|
||||
}: Props) {
|
||||
@@ -49,7 +47,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,8 +7,20 @@ const TeamLogo = styled(Avatar).attrs({
|
||||
variant: AvatarVariant.Square,
|
||||
})`
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
z-index: -1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
import Template from "~/models/Template";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const TemplateEdit = observer(function TemplateEdit_({
|
||||
template,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { templates } = useStores();
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
await template?.save();
|
||||
onSubmit?.();
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
}, [template, onSubmit]);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { InputIcon, ShapesIcon } from "outline-icons";
|
||||
import React, { useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import Template from "~/models/Template";
|
||||
import Editor from "~/scenes/Document/components/Editor";
|
||||
import { DocumentContextProvider } from "~/components/DocumentContext";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Notice from "~/components/Notice";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
export const TemplateForm = observer(function TemplateForm_({
|
||||
handleSubmit,
|
||||
template,
|
||||
}: {
|
||||
handleSubmit: (template: Template) => void;
|
||||
template: Template;
|
||||
}) {
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(template);
|
||||
const dataRef = useRef(template.data);
|
||||
const ref = useRef(null);
|
||||
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
|
||||
const readOnly = !can.update && !template.isNew;
|
||||
|
||||
const handleChangeTitle = (title: string) => {
|
||||
template.title = title;
|
||||
};
|
||||
|
||||
const handleChangeIcon = (icon: string, color: string) => {
|
||||
template.icon = icon;
|
||||
template.color = color;
|
||||
};
|
||||
|
||||
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
|
||||
dataRef.current = value(false);
|
||||
};
|
||||
|
||||
const handleSave = (options: { autosave?: boolean }) => {
|
||||
if (options.autosave) {
|
||||
return;
|
||||
}
|
||||
template.data = dataRef.current;
|
||||
handleSubmit(template);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<React.Suspense fallback={null}>
|
||||
{isUploading && <LoadingIndicator />}
|
||||
<Notice
|
||||
icon={<ShapesIcon />}
|
||||
description={
|
||||
<Trans>
|
||||
Highlight some text and use the <PlaceholderIcon /> control to add
|
||||
placeholders that can be filled out when creating new documents
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{t("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,30 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import Template from "~/models/Template";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
export const TemplateNew = observer(function TemplateNew_({ onSubmit }: Props) {
|
||||
const { templates } = useStores();
|
||||
const [template] = useState(new Template({ title: "" }, templates));
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
await template.save();
|
||||
onSubmit?.();
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
}, [template, onSubmit]);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -88,7 +88,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "workspace"}
|
||||
onChange={handleSelection}
|
||||
label={t("Location")}
|
||||
label={t("Visibility")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,8 @@ import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Switch from "~/components/Switch";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import SelectLocation from "./SelectLocation";
|
||||
|
||||
type Props = {
|
||||
@@ -22,7 +21,6 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const [publish, setPublish] = React.useState(true);
|
||||
const [collectionId, setCollectionId] = React.useState(
|
||||
document.collectionId ?? null
|
||||
);
|
||||
@@ -30,13 +28,13 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize({
|
||||
collectionId,
|
||||
publish,
|
||||
publish: true,
|
||||
});
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [t, document, history, collectionId, publish]);
|
||||
}, [t, document, history, collectionId]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
@@ -60,13 +58,6 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
defaultCollectionId={collectionId}
|
||||
onSelect={setCollectionId}
|
||||
/>
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Published")}
|
||||
note={t("Enable other members to use the template immediately")}
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -62,18 +61,9 @@ export default function useCollectionTrees(): NavigationNode[] {
|
||||
return node;
|
||||
};
|
||||
|
||||
const collectionNode: NavigationNode = {
|
||||
id: collection.id,
|
||||
title: collection.name,
|
||||
url: collection.path,
|
||||
type: NavigationNodeType.Collection,
|
||||
children: collection.documents
|
||||
? sortNavigationNodes(collection.documents, collection.sort, true)
|
||||
: [],
|
||||
parent: null,
|
||||
};
|
||||
|
||||
return addParent(addCollectionId(addDepth(addType(collectionNode), 1)));
|
||||
return addParent(
|
||||
addCollectionId(addDepth(addType(collection.asNavigationNode)))
|
||||
);
|
||||
};
|
||||
|
||||
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
@@ -41,6 +40,7 @@ import usePolicy from "./usePolicy";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
import Template from "~/models/Template";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the actions are generated */
|
||||
@@ -50,7 +50,7 @@ type Props = {
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
@@ -103,7 +103,6 @@ export function useDocumentMenuAction({
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
|
||||
@@ -14,12 +14,13 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionV2 } from "~/types";
|
||||
import { useComputed } from "./useComputed";
|
||||
import Template from "~/models/Template";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,11 +36,11 @@ type Props = {
|
||||
*/
|
||||
export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { templates } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const templateToAction = useCallback(
|
||||
(template: Document): ActionV2 =>
|
||||
(template: Template): ActionV2 =>
|
||||
createActionV2({
|
||||
name: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
@@ -62,11 +63,7 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
);
|
||||
|
||||
const collectionTemplatesActions = templates
|
||||
const collectionTemplatesActions = templates.orderedData
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
@@ -74,8 +71,8 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
|
||||
)
|
||||
.map(templateToAction);
|
||||
|
||||
const workspaceTemplatesActions = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
const workspaceTemplatesActions = templates.orderedData
|
||||
.filter((template) => template.isWorkspaceTemplate)
|
||||
.map(templateToAction);
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Template from "~/models/Template";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook provides a memoized list of menu items for both collection-specific
|
||||
* templates and workspace-wide templates. It filters templates based on whether
|
||||
* they are published and organizes them into appropriate sections.
|
||||
*
|
||||
* Collection-specific templates are displayed first, followed by workspace templates
|
||||
* with a separator in between (if both types exist).
|
||||
*
|
||||
* @returns An array of MenuItem objects representing templates that can be applied
|
||||
* to the current document. Returns an empty array if no callback is provided.
|
||||
*/
|
||||
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { templates } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const templateToMenuItem = useCallback(
|
||||
(template: Template): MenuItem => ({
|
||||
type: "button",
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
user
|
||||
),
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
onClick: () => onSelectTemplate?.(template),
|
||||
}),
|
||||
[user, onSelectTemplate]
|
||||
);
|
||||
|
||||
const collectionItems = templates.orderedData
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceTemplates = templates.orderedData
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceItems: MenuItem[] = useMemo(
|
||||
() =>
|
||||
workspaceTemplates.length
|
||||
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
|
||||
: [],
|
||||
[t, workspaceTemplates]
|
||||
);
|
||||
|
||||
if (!onSelectTemplate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectionItems
|
||||
? workspaceItems.length
|
||||
? [
|
||||
...collectionItems,
|
||||
{ type: "separator" } as MenuItem,
|
||||
...workspaceItems,
|
||||
]
|
||||
: collectionItems
|
||||
: workspaceItems;
|
||||
}
|
||||
@@ -28,14 +28,13 @@ import {
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
createDocument,
|
||||
exportCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { createDocument } from "~/actions/definitions/documents";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
@@ -202,7 +201,6 @@ function CollectionMenu({
|
||||
}),
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
createTemplate,
|
||||
sortAction,
|
||||
exportCollection,
|
||||
archiveCollection,
|
||||
|
||||
@@ -18,6 +18,7 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuSeparator } from "~/components/primitives/components/Menu";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import Template from "~/models/Template";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
@@ -32,8 +33,8 @@ type Props = {
|
||||
showToggleEmbeds?: boolean;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
/** Invoked when the "Apply template" menu item is clicked */
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Invoked when menu is opened */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DuplicateIcon, EditIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Template from "~/models/Template";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { deleteTemplate, moveTemplate } from "~/actions/definitions/templates";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
onEdit?: () => void;
|
||||
};
|
||||
|
||||
function TemplateMenu({ template, onEdit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { templates } = useStores();
|
||||
const can = usePolicy(template);
|
||||
|
||||
const section = "Template";
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: `${t("Edit")}…`,
|
||||
visible: !!can.update,
|
||||
icon: <EditIcon />,
|
||||
section,
|
||||
perform: () => onEdit?.(),
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Duplicate"),
|
||||
visible: !!can.duplicate,
|
||||
icon: <DuplicateIcon />,
|
||||
section,
|
||||
perform: () => templates.duplicate(template),
|
||||
}),
|
||||
moveTemplate,
|
||||
ActionV2Separator,
|
||||
deleteTemplate,
|
||||
],
|
||||
[can.update, can.duplicate, onEdit]
|
||||
);
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeTemplateId: template.id }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Template options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TemplateMenu);
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { ShapesIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
@@ -13,7 +14,7 @@ type Props = {
|
||||
/** Whether to render the button as a compact icon */
|
||||
isCompact?: boolean;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
onSelectTemplate: (template: Template) => void;
|
||||
};
|
||||
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
|
||||
@@ -356,7 +356,9 @@ export default class Collection extends ParanoidModel {
|
||||
title: this.name,
|
||||
color: this.color ?? undefined,
|
||||
icon: this.icon ?? undefined,
|
||||
children: this.documents ?? [],
|
||||
children: this.documents
|
||||
? sortNavigationNodes(this.documents, this.sort, true)
|
||||
: [],
|
||||
url: this.url,
|
||||
};
|
||||
}
|
||||
|
||||
+4
-24
@@ -25,7 +25,6 @@ import DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
import type { Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import Notification from "./Notification";
|
||||
import Pin from "./Pin";
|
||||
@@ -147,12 +146,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* Whether this is a template.
|
||||
*/
|
||||
@observable
|
||||
template: boolean;
|
||||
|
||||
/**
|
||||
* Whether the document layout is displayed full page width.
|
||||
*/
|
||||
@@ -267,20 +260,17 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
const prefix =
|
||||
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
|
||||
|
||||
if (!this.title) {
|
||||
return `${prefix}/untitled-${this.urlId}`;
|
||||
return `/doc/untitled-${this.urlId}`;
|
||||
}
|
||||
|
||||
const slugifiedTitle = slugify(this.title);
|
||||
return `${prefix}/${slugifiedTitle}-${this.urlId}`;
|
||||
return `/doc/${slugifiedTitle}-${this.urlId}`;
|
||||
}
|
||||
|
||||
@computed
|
||||
get noun(): string {
|
||||
return this.template ? t("template") : t("document");
|
||||
return t("document");
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -372,11 +362,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isTemplate(): boolean {
|
||||
return !!this.template;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDraft(): boolean {
|
||||
return !this.publishedAt;
|
||||
@@ -442,11 +427,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return path.map((item) => item.asNavigationNode);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isWorkspaceTemplate() {
|
||||
return this.template && !this.collectionId;
|
||||
}
|
||||
|
||||
get titleWithDefault(): string {
|
||||
return this.title || i18n.t("Untitled");
|
||||
}
|
||||
@@ -640,7 +620,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get isActive(): boolean {
|
||||
return !this.isDeleted && !this.isTemplate && !this.isArchived;
|
||||
return !this.isDeleted && !this.isArchived;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { addDays } from "date-fns";
|
||||
import i18n from "i18next";
|
||||
import { computed, observable } from "mobx";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import TemplatesStore from "~/stores/TemplatesStore";
|
||||
import User from "~/models/User";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
export default class Template extends ParanoidModel implements Searchable {
|
||||
static modelName = "Template";
|
||||
|
||||
store: TemplatesStore;
|
||||
|
||||
@Field
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
@computed
|
||||
get searchContent(): string {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the collection that this template belongs to, if any.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
collectionId?: string | null;
|
||||
|
||||
/**
|
||||
* The collection that this template belongs to.
|
||||
*/
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection?: Collection;
|
||||
|
||||
/**
|
||||
* The title of the template.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* An icon (or) emoji to use as the template icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
icon?: string | null;
|
||||
|
||||
/**
|
||||
* The color to use for the template icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* Whether the template layout is displayed full page width.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
fullWidth: boolean;
|
||||
|
||||
@Relation(() => User)
|
||||
createdBy: User | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
updatedBy: User | undefined;
|
||||
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
/**
|
||||
* Returns the direction of the template text, either "rtl" or "ltr"
|
||||
*/
|
||||
@computed
|
||||
get dir(): "rtl" | "ltr" {
|
||||
return this.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the template text is right-to-left
|
||||
*/
|
||||
@computed
|
||||
get rtl() {
|
||||
return isRTL(this.title);
|
||||
}
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
if (!this.title) {
|
||||
return `${settingsPath("templates")}/untitled-${this.urlId}`;
|
||||
}
|
||||
|
||||
const slugifiedTitle = slugify(this.title);
|
||||
return `${settingsPath("templates")}/${slugifiedTitle}-${this.urlId}`;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDeleted(): boolean {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get hasEmptyTitle(): boolean {
|
||||
return this.title === "";
|
||||
}
|
||||
|
||||
@computed
|
||||
get isWorkspaceTemplate(): boolean {
|
||||
return !this.collectionId;
|
||||
}
|
||||
|
||||
@computed
|
||||
get permanentlyDeletedAt(): string | undefined {
|
||||
if (!this.deletedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return addDays(new Date(this.deletedAt), 30).toString();
|
||||
}
|
||||
|
||||
get titleWithDefault(): string {
|
||||
return this.title || i18n.t("Untitled");
|
||||
}
|
||||
|
||||
@computed
|
||||
get isActive(): boolean {
|
||||
return !this.isDeleted;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default abstract class Model {
|
||||
|
||||
store: Store<Model>;
|
||||
|
||||
constructor(fields: Record<string, any>, store: Store<Model>) {
|
||||
constructor(fields: Record<string, any> = {}, store: Store<Model>) {
|
||||
this.store = store;
|
||||
this.updateData(fields);
|
||||
this.isNew = !this.id;
|
||||
|
||||
+6
-14
@@ -1,13 +1,12 @@
|
||||
import { RouteComponentProps, Switch } from "react-router-dom";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import { Switch } from "react-router-dom";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
const Application = lazy(() => import("~/scenes/Settings/Application"));
|
||||
const Document = lazy(() => import("~/scenes/Document"));
|
||||
const Template = lazy(() => import("~/scenes/Settings/Template"));
|
||||
|
||||
export default function SettingsRoutes() {
|
||||
const configs = useSettingsConfig();
|
||||
@@ -25,20 +24,13 @@ export default function SettingsRoutes() {
|
||||
{/* TODO: Refactor these exceptions into config? */}
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("applications")}/:id`}
|
||||
path={settingsPath("applications", ":id")}
|
||||
component={Application}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("templates")}/new`}
|
||||
component={(props: RouteComponentProps) => (
|
||||
<DocumentNew {...props} template />
|
||||
)}
|
||||
path={settingsPath("templates", ":id")}
|
||||
component={Template}
|
||||
/>
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
|
||||
@@ -22,7 +22,6 @@ function Archive() {
|
||||
<Empty>{t("The document archive is empty at the moment.")}</Empty>
|
||||
}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -169,7 +169,7 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
// If we're attempting to update an archived, deleted, or otherwise
|
||||
// uneditable document then forward to the canonical read url.
|
||||
if (!can.update && isEditRoute && !document.template) {
|
||||
if (!can.update && isEditRoute) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,11 @@ import { isModKey } from "@shared/utils/keyboard";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Template from "~/models/Template";
|
||||
import ConnectionStatus from "~/scenes/Document/components/ConnectionStatus";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import Branding from "~/components/Branding";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
@@ -154,7 +155,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
* @param selection The selection to replace, if any
|
||||
*/
|
||||
replaceSelection = (
|
||||
template: Document | Revision,
|
||||
template: Template | Revision,
|
||||
selection?: TextSelection | AllSelection
|
||||
) => {
|
||||
const editorRef = this.editor.current;
|
||||
@@ -179,7 +180,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
this.isEditorDirty = true;
|
||||
|
||||
if (template instanceof Document) {
|
||||
if (template instanceof Template) {
|
||||
this.props.document.templateId = template.id;
|
||||
this.props.document.fullWidth = template.fullWidth;
|
||||
}
|
||||
@@ -456,7 +457,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
tocPos &&
|
||||
(isShare
|
||||
? ui.tocVisible !== false
|
||||
: !document.isTemplate && ui.tocVisible === true);
|
||||
: !(document instanceof Template) && ui.tocVisible === true);
|
||||
const tocOffset =
|
||||
tocPos === TOCPosition.Left
|
||||
? EditorStyleHelper.tocWidth / -2
|
||||
@@ -587,7 +588,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
multiplayer={multiplayerEditor}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
template={document instanceof Template}
|
||||
document={document}
|
||||
value={readOnly ? document.data : undefined}
|
||||
defaultValue={document.data}
|
||||
|
||||
@@ -62,10 +62,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
</CommentLink>
|
||||
</>
|
||||
)}
|
||||
{totalViewers &&
|
||||
can.listViews &&
|
||||
!document.isDraft &&
|
||||
!document.isTemplate ? (
|
||||
{totalViewers && can.listViews && !document.isDraft ? (
|
||||
<Wrapper>
|
||||
<Separator />
|
||||
<InsightsButton action={openDocumentInsights}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TeamPreference } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import Template from "~/models/Template";
|
||||
import { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
@@ -40,7 +41,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
|
||||
onChangeTitle: (title: string) => void;
|
||||
onChangeIcon: (icon: string | null, color: string | null) => void;
|
||||
id: string;
|
||||
document: Document;
|
||||
document: Document | Template;
|
||||
isDraft: boolean;
|
||||
multiplayer?: boolean;
|
||||
onSave: (options: {
|
||||
@@ -48,7 +49,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
|
||||
autosave?: boolean;
|
||||
publish?: boolean;
|
||||
}) => void;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -210,7 +211,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
{t("Last updated")} <Time dateTime={document.updatedAt} addSuffix />
|
||||
</SharedMeta>
|
||||
) : null
|
||||
) : (
|
||||
) : document instanceof Document ? (
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
to={
|
||||
@@ -226,7 +227,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
}
|
||||
rtl={direction === "rtl"}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<EditorComponent
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
autoFocus={!!document.title && !props.defaultValue}
|
||||
@@ -252,7 +253,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
editorStyle={editorStyle}
|
||||
{...rest}
|
||||
/>
|
||||
<div ref={childRef}>{children}</div>
|
||||
{children && <div ref={childRef}>{children}</div>}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NavigationNode } from "@shared/types";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Template from "~/models/Template";
|
||||
import { Action, Separator } from "~/components/Actions";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
@@ -21,7 +22,6 @@ import Header from "~/components/Header";
|
||||
import Star from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -52,7 +52,7 @@ type Props = {
|
||||
isPublishing: boolean;
|
||||
publishingIsDisabled: boolean;
|
||||
savingIsDisabled: boolean;
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
onSelectTemplate: (template: Template) => void;
|
||||
onSave: (options: {
|
||||
done?: boolean;
|
||||
publish?: boolean;
|
||||
@@ -109,12 +109,10 @@ function DocumentHeader({
|
||||
}, [ui, isShare]);
|
||||
|
||||
const can = usePolicy(document);
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
const { isDeleted } = document;
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
|
||||
const toc = (
|
||||
<Tooltip
|
||||
@@ -219,11 +217,7 @@ function DocumentHeader({
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{document.isTemplate ? null : (
|
||||
<>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</>
|
||||
)}
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</DocumentBreadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -249,24 +243,21 @@ function DocumentHeader({
|
||||
limit={isCompact ? 3 : undefined}
|
||||
/>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) &&
|
||||
!isTemplate &&
|
||||
isNew &&
|
||||
can.update && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && !isRevision && !isTemplate && can.update && (
|
||||
{(isEditing || !user?.separateEditMode) && isNew && can.update && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && !isRevision && can.update && (
|
||||
<Action>
|
||||
<ShareButton document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{(isEditing || isTemplateEditable) && (
|
||||
{isEditing && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
@@ -274,8 +265,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
action={isTemplate ? navigateToTemplateSettings : undefined}
|
||||
onClick={isTemplate ? undefined : handleSave}
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
hideIcon
|
||||
@@ -316,9 +306,7 @@ function DocumentHeader({
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
>
|
||||
{document.collectionId || document.isWorkspaceTemplate
|
||||
? t("Publish")
|
||||
: `${t("Publish")}…`}
|
||||
{document.collectionId ? t("Publish") : `${t("Publish")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { TrashIcon, ArchiveIcon, ShapesIcon, InputIcon } from "outline-icons";
|
||||
import { TrashIcon, ArchiveIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Notice from "~/components/Notice";
|
||||
@@ -25,7 +24,7 @@ function Days(props: { dateTime: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Notices({ document, readOnly }: Props) {
|
||||
export default function Notices({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function permanentlyDeletedDescription() {
|
||||
@@ -41,12 +40,7 @@ export default function Notices({ document, readOnly }: Props) {
|
||||
? new Date().toISOString()
|
||||
: document.permanentlyDeletedAt;
|
||||
|
||||
return document.template ? (
|
||||
<Trans>
|
||||
This template will be permanently deleted in{" "}
|
||||
<Days dateTime={permanentlyDeletedAt} /> unless restored.
|
||||
</Trans>
|
||||
) : (
|
||||
return (
|
||||
<Trans>
|
||||
This document will be permanently deleted in{" "}
|
||||
<Days dateTime={permanentlyDeletedAt} /> unless restored.
|
||||
@@ -56,19 +50,6 @@ export default function Notices({ document, readOnly }: Props) {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{document.isTemplate && !readOnly && (
|
||||
<Notice
|
||||
icon={<ShapesIcon />}
|
||||
description={
|
||||
<Trans>
|
||||
Highlight some text and use the <PlaceholderIcon /> control to add
|
||||
placeholders that can be filled out when creating new documents
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{t("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,11 +8,7 @@ import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
collectionPath,
|
||||
documentPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { collectionPath, documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -25,8 +21,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const history = useHistory();
|
||||
const [isDeleting, setDeleting] = React.useState(false);
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const canArchive =
|
||||
!document.isDraft && !document.isArchived && !document.template;
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -55,12 +50,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// If template, redirect to the template settings.
|
||||
// Otherwise redirect to the collection (or) home.
|
||||
const path = document.template
|
||||
? settingsPath("templates")
|
||||
: collectionPath(collection?.path || "/");
|
||||
history.push(path);
|
||||
history.push(collectionPath(collection?.path || "/"));
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
@@ -93,17 +83,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{document.isTemplate ? (
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
|
||||
values={{
|
||||
documentTitle: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : nestedDocumentsCount < 1 ? (
|
||||
{nestedDocumentsCount < 1 ? (
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
|
||||
values={{
|
||||
|
||||
@@ -13,12 +13,7 @@ import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentEditPath, documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
// If true, the document will be created as a template.
|
||||
template?: boolean;
|
||||
};
|
||||
|
||||
function DocumentNew({ template }: Props) {
|
||||
function DocumentNew() {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
@@ -48,7 +43,6 @@ function DocumentNew({ template }: Props) {
|
||||
parentDocument?.fullWidth ||
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
templateId: query.get("templateId") ?? undefined,
|
||||
template,
|
||||
title: query.get("title") ?? "",
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
@@ -56,7 +50,7 @@ function DocumentNew({ template }: Props) {
|
||||
);
|
||||
|
||||
history.replace(
|
||||
template || !user.separateEditMode
|
||||
!user.separateEditMode
|
||||
? documentPath(document)
|
||||
: documentEditPath(document),
|
||||
location.state
|
||||
|
||||
@@ -318,7 +318,6 @@ function Search() {
|
||||
highlight={query}
|
||||
context={result.context}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
))
|
||||
: null
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, InternetIcon, ReplaceIcon, ShapesIcon } from "outline-icons";
|
||||
import { useEffect, useCallback, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { OAuthClientValidation } from "@shared/validations";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ContentEditable from "~/components/ContentEditable";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { FormData } from "~/components/OAuthClient/OAuthClientForm";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OAuthClientMenu from "~/menus/OAuthClientMenu";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { ActionRow } from "./components/ActionRow";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import Template from "~/models/Template";
|
||||
import TemplateMenu from "~/menus/TemplateMenu";
|
||||
import { TemplateForm } from "~/components/Template/TemplateForm";
|
||||
import { TemplateNew } from "~/components/Template/TemplateNew";
|
||||
import history from "~/utils/history";
|
||||
import { TemplateEdit } from "~/components/Template/TemplateEdit";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
};
|
||||
|
||||
const LoadingState = observer(function LoadingState() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { templates } = useStores();
|
||||
const template = templates.get(id);
|
||||
const { request } = useRequest(() => templates.fetch(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!template) {
|
||||
void request();
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
if (!template) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <TemplateSetting template={template} />;
|
||||
});
|
||||
|
||||
const TemplateSetting = observer(function Template_({ template }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const breadcrumbActions = useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Templates"),
|
||||
section: NavigationSection,
|
||||
icon: <ShapesIcon />,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
history.push(settingsPath("templates"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={template.title}
|
||||
left={<Breadcrumb actions={breadcrumbActions} />}
|
||||
actions={<TemplateMenu template={template} />}
|
||||
>
|
||||
{template ? <TemplateEdit template={template} onSubmit={handleSave} /> : <TemplateNew onSubmit={handleSave} />}
|
||||
</Scene>
|
||||
);
|
||||
});
|
||||
|
||||
export default LoadingState;
|
||||
@@ -1,75 +1,164 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import deburr from "lodash/deburr";
|
||||
import { observer } from "mobx-react";
|
||||
import { ShapesIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import { useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { PlusIcon, ShapesIcon } from "outline-icons";
|
||||
import { useEffect, useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Template from "~/models/Template";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import Text from "~/components/Text";
|
||||
import { createTemplate } from "~/actions/definitions/templates";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NewTemplateMenu from "~/menus/NewTemplateMenu";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { TemplatesTable } from "./components/TemplatesTable";
|
||||
|
||||
function getFilteredTemplates(templates: Template[], query?: string) {
|
||||
if (!query?.length) {
|
||||
return templates;
|
||||
}
|
||||
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
return templates.filter((template) =>
|
||||
deburr(template.title).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
function Templates() {
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const param = useQuery();
|
||||
const { fetchTemplates, templates, templatesAlphabetical } = documents;
|
||||
const sort = param.get("sort") || "recent";
|
||||
const { templates } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const params = useQuery();
|
||||
const context = useActionContext();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "createdAt",
|
||||
direction: (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredTemplates(templates.orderedData, reqParams.query),
|
||||
sort,
|
||||
reqFn: templates.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const isEmpty = !loading && !templates.orderedData.length;
|
||||
|
||||
const updateQuery = useCallback(
|
||||
(value: string) => {
|
||||
if (value) {
|
||||
params.set("query", value);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void documents.fetchDrafts();
|
||||
}, [documents]);
|
||||
if (error) {
|
||||
toast.error(t("Could not load templates"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<ShapesIcon />}
|
||||
title={t("Templates")}
|
||||
icon={<ShapesIcon />}
|
||||
actions={
|
||||
<Action>
|
||||
<NewTemplateMenu />
|
||||
</Action>
|
||||
<>
|
||||
{can.createTemplate && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
action={createTemplate}
|
||||
icon={<PlusIcon />}
|
||||
context={context}
|
||||
>
|
||||
{`${t("New template")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{t("Templates")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can create templates to help your team create consistent and
|
||||
accurate documentation.
|
||||
Templates help your team create consistent and accurate documentation.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<PaginatedDocumentList
|
||||
heading={
|
||||
<Tabs>
|
||||
<Tab to={settingsPath("templates")} exactQueryString>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={{
|
||||
pathname: settingsPath("templates"),
|
||||
search: queryString.stringify({
|
||||
sort: "alphabetical",
|
||||
}),
|
||||
{isEmpty ? (
|
||||
<Empty>{t("No templates have been created yet")}</Empty>
|
||||
) : (
|
||||
<>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<TemplatesTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
exactQueryString
|
||||
>
|
||||
{t("Alphabetical")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
}
|
||||
empty={<Empty>{t("There are no templates just yet.")}</Empty>}
|
||||
fetch={fetchTemplates}
|
||||
documents={sort === "alphabetical" ? templatesAlphabetical : templates}
|
||||
showCollection
|
||||
showDraft
|
||||
/>
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { hover } from "@shared/styles";
|
||||
import Template from "~/models/Template";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import { TemplateEdit } from "~/components/Template/TemplateEdit";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import TemplateMenu from "~/menus/TemplateMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<Template>, "columns" | "rowHeight">;
|
||||
|
||||
export function TemplatesTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleOpen = (template: Template) => () => {
|
||||
history.push(settingsPath("templates", template.id));
|
||||
};
|
||||
|
||||
const columns = React.useMemo<TableColumn<Template>[]>(
|
||||
() =>
|
||||
compact<TableColumn<Template>>([
|
||||
{
|
||||
type: "data",
|
||||
id: "title",
|
||||
header: t("Title"),
|
||||
accessor: (template) => template.titleWithDefault,
|
||||
component: (template) => (
|
||||
<ButtonLink onClick={handleOpen(template)}>
|
||||
<Flex align="center" gap={4}>
|
||||
{template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
color={template.color || undefined}
|
||||
size={24}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon size={24} color={theme.textSecondary} />
|
||||
)}
|
||||
<Title>{template.titleWithDefault}</Title>
|
||||
</Flex>
|
||||
</ButtonLink>
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "collectionId",
|
||||
header: t("Visibility"),
|
||||
accessor: (template) => template.collection?.name,
|
||||
component: (template) => <Permission template={template} />,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "lastModifiedById",
|
||||
header: t("Updated by"),
|
||||
accessor: (template) => template.updatedBy?.name,
|
||||
sortable: false,
|
||||
component: (template) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={template.updatedBy} size={AvatarSize.Small} />{" "}
|
||||
{template.updatedBy?.name}{" "}
|
||||
</Flex>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
header: t("Date created"),
|
||||
accessor: (title) => title.createdAt,
|
||||
component: (title) =>
|
||||
title.createdAt ? (
|
||||
<Time dateTime={title.createdAt} addSuffix />
|
||||
) : null,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (template) => (
|
||||
<TemplateMenu template={template} onEdit={handleOpen(template)} />
|
||||
),
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Permission = observer(({ template }: { template: Template }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
void template?.loadRelations();
|
||||
}, [template]);
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={4}>
|
||||
{template.collection ? (
|
||||
<CollectionIcon collection={template.collection} />
|
||||
) : null}
|
||||
{template.collectionId ? template.collection?.name : t("Workspace")}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const Title = styled(Text)`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
@@ -33,7 +33,6 @@ function Trash() {
|
||||
heading={<Subheading sticky>{t("Recently deleted")}</Subheading>}
|
||||
empty={<Empty>{t("Trash is empty at the moment.")}</Empty>}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -71,10 +71,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@computed
|
||||
get all(): Document[] {
|
||||
return filter(
|
||||
this.orderedData,
|
||||
(d) => !d.archivedAt && !d.deletedAt && !d.template
|
||||
);
|
||||
return filter(this.orderedData, (d) => !d.archivedAt && !d.deletedAt);
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -91,18 +88,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
return orderBy(this.all, "updatedAt", "desc");
|
||||
}
|
||||
|
||||
@computed
|
||||
get templates(): Document[] {
|
||||
return orderBy(
|
||||
filter(
|
||||
this.orderedData,
|
||||
(d) => !d.archivedAt && !d.deletedAt && d.template
|
||||
),
|
||||
"updatedAt",
|
||||
"desc"
|
||||
);
|
||||
}
|
||||
|
||||
createdByUser(userId: string): Document[] {
|
||||
return orderBy(
|
||||
filter(this.all, (d) => d.createdBy?.id === userId),
|
||||
@@ -145,21 +130,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
templatesInCollection(collectionId: string): Document[] {
|
||||
return orderBy(
|
||||
filter(
|
||||
this.orderedData,
|
||||
(d) =>
|
||||
!d.archivedAt &&
|
||||
!d.deletedAt &&
|
||||
d.template === true &&
|
||||
d.collectionId === collectionId
|
||||
),
|
||||
"updatedAt",
|
||||
"desc"
|
||||
);
|
||||
}
|
||||
|
||||
publishedInCollection(collectionId: string): Document[] {
|
||||
return filter(
|
||||
this.all,
|
||||
@@ -224,11 +194,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get templatesAlphabetical(): Document[] {
|
||||
return naturalSort(this.templates, "title");
|
||||
}
|
||||
|
||||
@computed
|
||||
get totalDrafts(): number {
|
||||
return this.drafts().length;
|
||||
@@ -481,10 +446,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
const doc: Document | null | undefined = this.data.get(id);
|
||||
invariant(doc, "Document should exist");
|
||||
|
||||
if (doc.template) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await client.post("/documents.templatize", {
|
||||
id,
|
||||
collectionId,
|
||||
|
||||
@@ -27,6 +27,7 @@ import SearchesStore from "./SearchesStore";
|
||||
import SharesStore from "./SharesStore";
|
||||
import StarsStore from "./StarsStore";
|
||||
import SubscriptionsStore from "./SubscriptionsStore";
|
||||
import TemplatesStore from "./TemplatesStore";
|
||||
import UiStore from "./UiStore";
|
||||
import UnfurlsStore from "./UnfurlsStore";
|
||||
import UserMembershipsStore from "./UserMembershipsStore";
|
||||
@@ -63,6 +64,7 @@ export default class RootStore {
|
||||
unfurls: UnfurlsStore;
|
||||
stars: StarsStore;
|
||||
subscriptions: SubscriptionsStore;
|
||||
templates: TemplatesStore;
|
||||
users: UsersStore;
|
||||
views: ViewsStore;
|
||||
fileOperations: FileOperationsStore;
|
||||
@@ -93,6 +95,7 @@ export default class RootStore {
|
||||
this.registerStore(SharesStore);
|
||||
this.registerStore(StarsStore);
|
||||
this.registerStore(SubscriptionsStore);
|
||||
this.registerStore(TemplatesStore);
|
||||
this.registerStore(UnfurlsStore);
|
||||
this.registerStore(UsersStore);
|
||||
this.registerStore(ViewsStore);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import find from "lodash/find";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { action, computed } from "mobx";
|
||||
import { invariant } from "mobx-utils";
|
||||
import naturalSort from "@shared/utils/naturalSort";
|
||||
import Template from "~/models/Template";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
export default class TemplatesStore extends Store<Template> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Template);
|
||||
}
|
||||
|
||||
@computed
|
||||
get alphabetical(): Template[] {
|
||||
return naturalSort(Array.from(this.data.values()), "title");
|
||||
}
|
||||
|
||||
@action
|
||||
duplicate = async (
|
||||
template: Template,
|
||||
options?: {
|
||||
title?: string;
|
||||
publish?: boolean;
|
||||
}
|
||||
) => {
|
||||
const res = await client.post("/templates.duplicate", {
|
||||
id: template.id,
|
||||
...options,
|
||||
});
|
||||
invariant(res?.data, "Data should be available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
this.add(res.data);
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Template | undefined =>
|
||||
find(
|
||||
this.orderedData,
|
||||
(template) => url.endsWith(template.urlId) || url.endsWith(template.id)
|
||||
);
|
||||
|
||||
@computed
|
||||
get active(): Template | undefined {
|
||||
return this.rootStore.ui.activeDocumentId
|
||||
? this.data.get(this.rootStore.ui.activeDocumentId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Template[] {
|
||||
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,9 @@ export type ActionContext = {
|
||||
isCommandBar: boolean;
|
||||
isButton: boolean;
|
||||
sidebarContext?: SidebarContextType;
|
||||
// TODO: Refactor this to data structure of active models
|
||||
activeCollectionId?: string | undefined;
|
||||
activeTemplateId?: string | undefined;
|
||||
activeDocumentId: string | undefined;
|
||||
currentUserId: string | undefined;
|
||||
currentTeamId: string | undefined;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -114,6 +114,7 @@ type AdditionalFindOptions = {
|
||||
[Op.is]: null,
|
||||
},
|
||||
},
|
||||
template: false,
|
||||
},
|
||||
attributes: {
|
||||
include: [stateIfContentEmpty],
|
||||
@@ -857,13 +858,6 @@ class Document extends ArchivableModel<
|
||||
return !!(this.importId && this.sourceMetadata?.trial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this document is a template created at the workspace level.
|
||||
*/
|
||||
get isWorkspaceTemplate() {
|
||||
return this.template && !this.collectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the state of the document to match the passed revision.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { isUUID } from "class-validator";
|
||||
import {
|
||||
Identifier,
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
NonNullFindOptions,
|
||||
Op,
|
||||
FindOptions,
|
||||
EmptyResultError,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
DataType,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Table,
|
||||
Length as SimpleLength,
|
||||
DefaultScope,
|
||||
Default,
|
||||
HasMany,
|
||||
Unique,
|
||||
Scopes,
|
||||
BeforeValidate,
|
||||
} from "sequelize-typescript";
|
||||
import slugify from "slugify";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { generateUrlId } from "@server/utils/url";
|
||||
import Collection from "./Collection";
|
||||
import Revision from "./Revision";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import IsHexColor from "./validators/IsHexColor";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
type AdditionalFindOptions = {
|
||||
userId?: string;
|
||||
includeState?: boolean;
|
||||
rejectOnEmpty?: boolean | Error;
|
||||
};
|
||||
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
association: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
association: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
where: {
|
||||
archivedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
template: true,
|
||||
},
|
||||
attributes: {
|
||||
exclude: ["state"],
|
||||
},
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withMembership: (userId: string, paranoid = true) => ({
|
||||
include: [
|
||||
{
|
||||
model: userId
|
||||
? Collection.scope([
|
||||
"defaultScope",
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
},
|
||||
])
|
||||
: Collection,
|
||||
as: "collection",
|
||||
paranoid,
|
||||
},
|
||||
],
|
||||
}),
|
||||
withCollection: {
|
||||
include: [
|
||||
{
|
||||
model: Collection,
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "documents", modelName: "template" })
|
||||
@Fix
|
||||
class Template extends IdModel<
|
||||
InferAttributes<Template>,
|
||||
Partial<InferCreationAttributes<Template>>
|
||||
> {
|
||||
@SimpleLength({
|
||||
min: 10,
|
||||
max: 10,
|
||||
msg: `urlId must be 10 characters`,
|
||||
})
|
||||
@Unique
|
||||
@Column
|
||||
urlId: string;
|
||||
|
||||
@Length({
|
||||
max: DocumentValidation.maxTitleLength,
|
||||
msg: `Template title must be ${DocumentValidation.maxTitleLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
title: string;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
fullWidth: boolean;
|
||||
|
||||
/** The version of the editor last used to edit this document. */
|
||||
@SimpleLength({
|
||||
max: 255,
|
||||
msg: `editorVersion must be 255 characters or less`,
|
||||
})
|
||||
@Column
|
||||
editorVersion: string;
|
||||
|
||||
/** An icon to use as the template icon. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
})
|
||||
@Column
|
||||
icon: string | null;
|
||||
|
||||
/** The color of the icon. */
|
||||
@IsHexColor
|
||||
@Column
|
||||
color: string | null;
|
||||
|
||||
/**
|
||||
* The content of the template as JSON, this is a snapshot at the last time the state was saved.
|
||||
*/
|
||||
@Column(DataType.JSONB)
|
||||
content: ProsemirrorData | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "lastModifiedById")
|
||||
updatedBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
lastModifiedById: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection | null;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId?: string | null;
|
||||
|
||||
@HasMany(() => Revision, "documentId")
|
||||
revisions: Revision[];
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
template: boolean;
|
||||
|
||||
// getters
|
||||
|
||||
/** The frontend path to this template. */
|
||||
get path() {
|
||||
if (!this.title) {
|
||||
return `/settings/templates/untitled-${this.urlId}`;
|
||||
}
|
||||
const slugifiedTitle = slugify(this.title);
|
||||
return `/settings/templates/${slugifiedTitle}-${this.urlId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this is a workspace template.
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
get isWorkspaceTemplate() {
|
||||
return !this.collectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that returns whether this template is deleted.
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
get isDeleted(): boolean {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@BeforeValidate
|
||||
static createUrlId(model: Template) {
|
||||
return (model.urlId = model.urlId || generateUrlId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the standard findByPk behavior to allow also querying by urlId
|
||||
*
|
||||
* @param id uuid or urlId
|
||||
* @param options FindOptions
|
||||
* @returns A promise resolving to a template instance or null
|
||||
*/
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
options?: NonNullFindOptions<Template> & AdditionalFindOptions
|
||||
): Promise<Template>;
|
||||
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
options?: FindOptions<Template> & AdditionalFindOptions
|
||||
): Promise<Template | null>;
|
||||
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
options: (NonNullFindOptions<Template> | FindOptions<Template>) &
|
||||
AdditionalFindOptions = {}
|
||||
): Promise<Template | null> {
|
||||
if (typeof id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { includeState, userId, ...rest } = options;
|
||||
|
||||
// allow default preloading of collection membership if `userId` is passed in find options
|
||||
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||
const scope = this.scope([
|
||||
"defaultScope",
|
||||
{
|
||||
method: ["withMembership", userId, rest.paranoid],
|
||||
},
|
||||
]);
|
||||
|
||||
if (isUUID(id)) {
|
||||
const template = await scope.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
...rest,
|
||||
rejectOnEmpty: false,
|
||||
});
|
||||
|
||||
if (!template && rest.rejectOnEmpty) {
|
||||
throw new EmptyResultError(`Template doesn't exist with id: ${id}`);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
const match = id.match(UrlHelper.SLUG_URL_REGEX);
|
||||
if (match) {
|
||||
const template = await scope.findOne({
|
||||
where: {
|
||||
urlId: match[1],
|
||||
},
|
||||
...rest,
|
||||
rejectOnEmpty: false,
|
||||
});
|
||||
|
||||
if (!template && rest.rejectOnEmpty) {
|
||||
throw new EmptyResultError(`Template doesn't exist with id: ${id}`);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Template;
|
||||
@@ -10,7 +10,7 @@ import { determineIconType } from "@shared/utils/icon";
|
||||
import { parser, serializer, schema } from "@server/editor";
|
||||
import { addTags } from "@server/logging/tracer";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import { Collection, Document, Revision } from "@server/models";
|
||||
import { Collection, Document, Revision, Template } from "@server/models";
|
||||
import diff from "@server/utils/diff";
|
||||
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
@@ -73,7 +73,7 @@ export class DocumentHelper {
|
||||
* @returns The document content as a plain JSON object
|
||||
*/
|
||||
static async toJSON(
|
||||
document: Document | Revision | Collection,
|
||||
document: Document | Template | Revision | Collection,
|
||||
options?: {
|
||||
/** The team context */
|
||||
teamId?: string;
|
||||
@@ -85,7 +85,7 @@ export class DocumentHelper {
|
||||
internalUrlBase?: string;
|
||||
}
|
||||
): Promise<ProsemirrorData> {
|
||||
let doc: Node | null;
|
||||
let doc: Node | null | undefined;
|
||||
let data;
|
||||
|
||||
if ("content" in document && document.content) {
|
||||
@@ -104,8 +104,8 @@ export class DocumentHelper {
|
||||
doc = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
|
||||
} else if (document instanceof Collection) {
|
||||
doc = parser.parse(document.description ?? "");
|
||||
} else {
|
||||
doc = parser.parse(document.text ?? "");
|
||||
} else if (document instanceof Document) {
|
||||
doc = parser.parse(document.text);
|
||||
}
|
||||
|
||||
if (doc && options?.signedUrls && options?.teamId) {
|
||||
|
||||
@@ -56,6 +56,8 @@ export { default as Team } from "./Team";
|
||||
|
||||
export { default as TeamDomain } from "./TeamDomain";
|
||||
|
||||
export { default as Template } from "./Template";
|
||||
|
||||
export { default as User } from "./User";
|
||||
|
||||
export { default as UserAuthentication } from "./UserAuthentication";
|
||||
|
||||
+23
-78
@@ -15,23 +15,23 @@ allow(User, "createDocument", Team, (actor, document) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "read", Document, (actor, document) =>
|
||||
and(
|
||||
isTeamModel(actor, document),
|
||||
or(
|
||||
includesMembership(document, [
|
||||
DocumentPermission.Read,
|
||||
DocumentPermission.ReadWrite,
|
||||
DocumentPermission.Admin,
|
||||
]),
|
||||
and(!!document?.isDraft, actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
can(actor, "readTemplate", actor.team)
|
||||
),
|
||||
can(actor, "readDocument", document?.collection)
|
||||
allow(
|
||||
User,
|
||||
["read", "star", "unstar", "subscribe", "unsubscribe"],
|
||||
Document,
|
||||
(actor, document) =>
|
||||
and(
|
||||
isTeamModel(actor, document),
|
||||
or(
|
||||
includesMembership(document, [
|
||||
DocumentPermission.Read,
|
||||
DocumentPermission.ReadWrite,
|
||||
DocumentPermission.Admin,
|
||||
]),
|
||||
!!document?.isDraft && actor.id === document?.createdById,
|
||||
can(actor, "readDocument", document?.collection)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
|
||||
@@ -60,29 +60,15 @@ allow(User, "comment", Document, (actor, document) =>
|
||||
),
|
||||
isTeamMutable(actor),
|
||||
!!document?.isActive,
|
||||
!document?.template,
|
||||
or(!document?.collection, document?.collection?.commenting !== false)
|
||||
)
|
||||
);
|
||||
|
||||
allow(
|
||||
User,
|
||||
["star", "unstar", "subscribe", "unsubscribe"],
|
||||
Document,
|
||||
(actor, document) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "read", document),
|
||||
!document?.template
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "share", Document, (actor, document) =>
|
||||
and(
|
||||
can(actor, "read", document),
|
||||
isTeamMutable(actor),
|
||||
!!document?.isActive,
|
||||
!document?.template,
|
||||
or(!document?.collection, can(actor, "share", document?.collection))
|
||||
)
|
||||
);
|
||||
@@ -99,14 +85,7 @@ allow(User, "update", Document, (actor, document) =>
|
||||
]),
|
||||
or(
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
)
|
||||
)
|
||||
!!document?.isDraft && actor.id === document?.createdById
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -122,7 +101,6 @@ allow(User, "publish", Document, (actor, document) =>
|
||||
|
||||
allow(User, "manageUsers", Document, (actor, document) =>
|
||||
and(
|
||||
!document?.template,
|
||||
can(actor, "update", document),
|
||||
or(
|
||||
includesMembership(document, [DocumentPermission.Admin]),
|
||||
@@ -140,14 +118,7 @@ allow(User, "duplicate", Document, (actor, document) =>
|
||||
includesMembership(document, [DocumentPermission.Admin]),
|
||||
and(isTeamAdmin(actor, document), can(actor, "read", document)),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
!!document?.isDraft && actor.id === document?.createdById,
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
)
|
||||
)
|
||||
!!document?.isDraft && actor.id === document?.createdById
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -158,20 +129,13 @@ allow(User, "move", Document, (actor, document) =>
|
||||
or(
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(!!document?.isDraft && !document?.collection),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
)
|
||||
)
|
||||
and(!!document?.isDraft && !document?.collection)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "createChildDocument", Document, (actor, document) =>
|
||||
and(can(actor, "update", document), !document?.isDraft, !document?.template)
|
||||
and(can(actor, "update", document), !document?.isDraft)
|
||||
);
|
||||
|
||||
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
|
||||
@@ -179,7 +143,6 @@ allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
|
||||
can(actor, "update", document),
|
||||
can(actor, "update", document?.collection),
|
||||
!document?.isDraft,
|
||||
!document?.template,
|
||||
!actor.isGuest
|
||||
)
|
||||
);
|
||||
@@ -190,7 +153,6 @@ allow(User, "pinToHome", Document, (actor, document) =>
|
||||
isTeamAdmin(actor, document),
|
||||
isTeamMutable(actor),
|
||||
!document?.isDraft,
|
||||
!document?.template,
|
||||
!!document?.isActive
|
||||
)
|
||||
);
|
||||
@@ -200,11 +162,7 @@ allow(User, "delete", Document, (actor, document) =>
|
||||
isTeamModel(actor, document),
|
||||
isTeamMutable(actor),
|
||||
!document?.isDeleted,
|
||||
or(
|
||||
can(actor, "unarchive", document),
|
||||
can(actor, "update", document),
|
||||
and(!document?.isWorkspaceTemplate, !document?.collection)
|
||||
)
|
||||
or(can(actor, "unarchive", document), can(actor, "update", document))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -219,11 +177,7 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
|
||||
DocumentPermission.Admin,
|
||||
]),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
),
|
||||
!!document?.isDraft && actor.id === document?.createdById,
|
||||
!document?.collection
|
||||
)
|
||||
)
|
||||
@@ -231,7 +185,6 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
|
||||
|
||||
allow(User, "archive", Document, (actor, document) =>
|
||||
and(
|
||||
!document?.template,
|
||||
!document?.isDraft,
|
||||
!!document?.isActive,
|
||||
can(actor, "update", document),
|
||||
@@ -245,7 +198,6 @@ allow(User, "archive", Document, (actor, document) =>
|
||||
|
||||
allow(User, "unarchive", Document, (actor, document) =>
|
||||
and(
|
||||
!document?.template,
|
||||
!document?.isDraft,
|
||||
!document?.isDeleted,
|
||||
!!document?.archivedAt,
|
||||
@@ -256,7 +208,7 @@ allow(User, "unarchive", Document, (actor, document) =>
|
||||
DocumentPermission.Admin,
|
||||
]),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById)
|
||||
!!document?.isDraft && actor.id === document?.createdById
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -279,13 +231,6 @@ allow(User, "unpublish", Document, (user, document) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
document.isWorkspaceTemplate &&
|
||||
(user.id === document.createdById || can(user, "updateTemplate", user.team))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildUser, buildTeam } from "@server/test/factories";
|
||||
import { buildUser, buildAdmin, buildTeam } from "@server/test/factories";
|
||||
import { serialize } from "./index";
|
||||
|
||||
it("should serialize policy", async () => {
|
||||
@@ -10,7 +10,7 @@ it("should serialize policy", async () => {
|
||||
|
||||
it("should serialize domain policies on Team", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
const user = await buildAdmin({
|
||||
teamId: team.id,
|
||||
});
|
||||
const response = serialize(user, team);
|
||||
|
||||
@@ -23,6 +23,7 @@ import "./star";
|
||||
import "./subscription";
|
||||
import "./user";
|
||||
import "./team";
|
||||
import "./template";
|
||||
import "./group";
|
||||
import "./webhookSubscription";
|
||||
import "./userMembership";
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("policies/team", () => {
|
||||
expect(abilities.createTeam).toEqual(false);
|
||||
expect(abilities.createAttachment).toBeTruthy();
|
||||
expect(abilities.createCollection).toBeTruthy();
|
||||
expect(abilities.createTemplate).toBeTruthy();
|
||||
expect(abilities.createTemplate).toEqual(false);
|
||||
expect(abilities.createGroup).toEqual(false);
|
||||
expect(abilities.createIntegration).toEqual(false);
|
||||
});
|
||||
@@ -53,31 +53,10 @@ describe("policies/team", () => {
|
||||
expect(abilities.createIntegration).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("read template", () => {
|
||||
const permissions = new Map<UserRole, boolean>([
|
||||
[UserRole.Admin, true],
|
||||
[UserRole.Member, true],
|
||||
[UserRole.Viewer, true],
|
||||
[UserRole.Guest, true],
|
||||
]);
|
||||
for (const [role, permission] of permissions.entries()) {
|
||||
it(`check permission for ${role}`, async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
teamId: team.id,
|
||||
role,
|
||||
});
|
||||
|
||||
const abilities = serialize(user, team);
|
||||
expect(abilities.readTemplate).toEqual(permission);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("create template", () => {
|
||||
const permissions = new Map<UserRole, boolean>([
|
||||
[UserRole.Admin, true],
|
||||
[UserRole.Member, true],
|
||||
[UserRole.Member, false],
|
||||
[UserRole.Viewer, false],
|
||||
[UserRole.Guest, false],
|
||||
]);
|
||||
|
||||
+1
-27
@@ -1,13 +1,6 @@
|
||||
import { Team, User } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import {
|
||||
and,
|
||||
isCloudHosted,
|
||||
isTeamAdmin,
|
||||
isTeamModel,
|
||||
isTeamMutable,
|
||||
or,
|
||||
} from "./utils";
|
||||
import { and, isCloudHosted, isTeamAdmin, isTeamModel, or } from "./utils";
|
||||
|
||||
allow(User, ["read", "readTemplate"], Team, isTeamModel);
|
||||
|
||||
@@ -39,22 +32,3 @@ allow(User, ["delete", "audit"], Team, (actor, team) =>
|
||||
isTeamAdmin(actor, team)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "createTemplate", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
!actor.isGuest,
|
||||
!actor.isViewer,
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "updateTemplate", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
actor.isAdmin,
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Template, User, Team } from "@server/models";
|
||||
import { allow, can } from "./cancan";
|
||||
import { and, isTeamModel, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, ["createTemplate", "updateTemplate"], Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
actor.isAdmin,
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "read", Template, (actor, template) =>
|
||||
and(
|
||||
isTeamModel(actor, template),
|
||||
or(
|
||||
and(!!template?.isWorkspaceTemplate, can(actor, "read", actor.team)),
|
||||
can(actor, "readDocument", template?.collection)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "listRevisions", Template, (actor, template) =>
|
||||
or(
|
||||
and(can(actor, "read", template), !actor.isGuest),
|
||||
and(can(actor, "update", template), actor.isGuest)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["update", "move", "duplicate"], Template, (actor, template) =>
|
||||
and(
|
||||
can(actor, "read", template),
|
||||
isTeamMutable(actor),
|
||||
or(
|
||||
and(
|
||||
!!template?.isWorkspaceTemplate,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
),
|
||||
can(actor, "update", template?.collection)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "delete", Template, (actor, template) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "update", template),
|
||||
!template?.isDeleted
|
||||
)
|
||||
);
|
||||
@@ -26,6 +26,7 @@ import presentShare from "./share";
|
||||
import presentStar from "./star";
|
||||
import presentSubscription from "./subscription";
|
||||
import presentTeam from "./team";
|
||||
import presentTemplate from "./template";
|
||||
import presentUser from "./user";
|
||||
import presentView from "./view";
|
||||
|
||||
@@ -59,6 +60,7 @@ export {
|
||||
presentStar,
|
||||
presentSubscription,
|
||||
presentTeam,
|
||||
presentTemplate,
|
||||
presentUser,
|
||||
presentView,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Template } from "@server/models";
|
||||
import presentUser from "./user";
|
||||
|
||||
function presentTemplate(template: Template) {
|
||||
return {
|
||||
id: template.id,
|
||||
url: template.path,
|
||||
urlId: template.urlId,
|
||||
title: template.title,
|
||||
data: template.content,
|
||||
icon: template.icon,
|
||||
color: template.color,
|
||||
createdAt: template.createdAt,
|
||||
createdBy: presentUser(template.createdBy),
|
||||
updatedAt: template.updatedAt,
|
||||
updatedBy: presentUser(template.updatedBy),
|
||||
deletedAt: template.deletedAt,
|
||||
fullWidth: template.fullWidth,
|
||||
collectionId: template.collectionId,
|
||||
};
|
||||
}
|
||||
|
||||
export default presentTemplate;
|
||||
@@ -911,8 +911,6 @@ export default class WebsocketsProcessor {
|
||||
channels.push(
|
||||
...this.getCollectionEventChannels(event, document.collection)
|
||||
);
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
channels.push(`team-${document.teamId}`);
|
||||
} else {
|
||||
channels.push(`collection-${document.collectionId}`);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
buildTeam,
|
||||
buildGroup,
|
||||
buildAdmin,
|
||||
buildTemplate,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
@@ -1984,8 +1985,9 @@ describe("#documents.templatize", () => {
|
||||
expect(body.data.publishedAt).toBeTruthy();
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should create a published workspace template", async () => {
|
||||
const user = await buildUser();
|
||||
const user = await buildAdmin();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
@@ -2007,6 +2009,7 @@ describe("#documents.templatize", () => {
|
||||
expect(body.data.publishedAt).toBeTruthy();
|
||||
expect(body.data.collectionId).toBeNull();
|
||||
});
|
||||
|
||||
it("should create a draft non-workspace template", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
@@ -2032,7 +2035,7 @@ describe("#documents.templatize", () => {
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
it("should create a draft workspace template", async () => {
|
||||
const user = await buildUser();
|
||||
const user = await buildAdmin();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
@@ -2054,6 +2057,7 @@ describe("#documents.templatize", () => {
|
||||
expect(body.data.publishedAt).toBeNull();
|
||||
expect(body.data.collectionId).toBeNull();
|
||||
});
|
||||
|
||||
it("should create a template in a different collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
@@ -2620,56 +2624,6 @@ describe("#documents.move", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should move a template to workspace", async () => {
|
||||
const user = await buildAdmin();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
template: true,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/documents.move", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.documents[0].collectionId).toBeNull();
|
||||
expect(body.policies[0].abilities.move).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should move a workspace template to collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/documents.move", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.documents[0].collectionId).toEqual(collection.id);
|
||||
expect(body.policies[0].abilities.move).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.move");
|
||||
expect(res.status).toEqual(401);
|
||||
@@ -2987,92 +2941,6 @@ describe("#documents.restore", () => {
|
||||
expect(body.data.collectionId).toEqual(anotherCollection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of collection templates", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const template = await buildDocument({
|
||||
template: true,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await template.delete(user);
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of templates from a deleted collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const anotherCollection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const template = await buildDocument({
|
||||
template: true,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await template.delete(user);
|
||||
await collection.destroy({ hooks: false });
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
collectionId: anotherCollection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(anotherCollection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of workspace templates", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const template = await buildDocument({
|
||||
template: true,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
collectionId: null,
|
||||
});
|
||||
await template.delete(user);
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(null);
|
||||
});
|
||||
|
||||
it("should not allow restore of documents to a deleted collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
@@ -3353,11 +3221,10 @@ describe("#documents.create", () => {
|
||||
});
|
||||
|
||||
it("should retain template variables when a template is created from another template", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
const user = await buildAdmin();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
text: "Created by user {author} on {date}",
|
||||
});
|
||||
@@ -3371,7 +3238,6 @@ describe("#documents.create", () => {
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual(template.title);
|
||||
expect(body.data.text).toEqual(template.text);
|
||||
});
|
||||
|
||||
it("should create a document with empty title if no title is explicitly passed", async () => {
|
||||
@@ -3389,10 +3255,9 @@ describe("#documents.create", () => {
|
||||
|
||||
it("should use template title when doc is supposed to be created using the template and title is not explicitly passed", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
text: "template text",
|
||||
});
|
||||
@@ -3405,15 +3270,13 @@ describe("#documents.create", () => {
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual(template.title);
|
||||
expect(body.data.text).toEqual(template.text);
|
||||
});
|
||||
|
||||
it("should override template title when doc title is explicitly passed", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
@@ -3570,7 +3433,7 @@ describe("#documents.create", () => {
|
||||
|
||||
it("should allow creating a draft template without a collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
template: true,
|
||||
@@ -3806,39 +3669,6 @@ describe("#documents.update", () => {
|
||||
expect(body.data.text).toBe("Updated text");
|
||||
});
|
||||
|
||||
it("should successfully publish a draft template without collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const document = await buildDraftDocument({
|
||||
title: "title",
|
||||
text: "text",
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: null,
|
||||
template: true,
|
||||
});
|
||||
const res = await server.post("/api/documents.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
title: "Updated title",
|
||||
text: "Updated text",
|
||||
collectionId: collection.id,
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.collectionId).toBe(collection.id);
|
||||
expect(body.data.title).toBe("Updated title");
|
||||
expect(body.data.text).toBe("Updated text");
|
||||
expect(body.data.publishedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not allow publishing by another collection's user", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
@@ -4456,33 +4286,6 @@ describe("#documents.delete", () => {
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("should allow deleting document without collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
// delete collection without hooks to trigger document deletion
|
||||
await collection.destroy({
|
||||
hooks: false,
|
||||
});
|
||||
const res = await server.post("/api/documents.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const document = await buildDocument();
|
||||
const res = await server.post("/api/documents.delete", {
|
||||
|
||||
@@ -56,7 +56,7 @@ import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { authorize, can, cannot } from "@server/policies";
|
||||
import { authorize, cannot } from "@server/policies";
|
||||
import {
|
||||
presentDocument,
|
||||
presentPolicies,
|
||||
@@ -90,7 +90,6 @@ router.post(
|
||||
const {
|
||||
sort,
|
||||
direction,
|
||||
template,
|
||||
collectionId,
|
||||
backlinkDocumentId,
|
||||
parentDocumentId,
|
||||
@@ -118,12 +117,6 @@ router.post(
|
||||
where[Op.and].push({ archivedAt: { [Op.eq]: null } });
|
||||
}
|
||||
|
||||
if (template) {
|
||||
where[Op.and].push({
|
||||
template: true,
|
||||
});
|
||||
}
|
||||
|
||||
// if a specific user is passed then add to filters. If the user doesn't
|
||||
// exist in the team then nothing will be returned, so no need to check auth
|
||||
if (createdById) {
|
||||
@@ -153,12 +146,7 @@ router.post(
|
||||
} else if (!backlinkDocumentId) {
|
||||
const collectionIds = await user.collectionIds();
|
||||
where[Op.and].push({
|
||||
collectionId:
|
||||
template && can(user, "readTemplate", user.team)
|
||||
? {
|
||||
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
|
||||
}
|
||||
: collectionIds,
|
||||
collectionId: collectionIds,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -894,8 +882,7 @@ router.post(
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// In case of workspace templates, both source and destination collections are undefined.
|
||||
if (!document.isWorkspaceTemplate && !destCollection?.isActive) {
|
||||
if (!destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
@@ -910,19 +897,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt && document.isWorkspaceTemplate) {
|
||||
authorize(user, "restore", document);
|
||||
|
||||
await document.restore({ transaction });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.restore",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
} else if (document.deletedAt) {
|
||||
if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
@@ -1278,7 +1253,7 @@ router.post(
|
||||
authorize(user, "publish", document);
|
||||
}
|
||||
|
||||
if (!document.collectionId && !document.isWorkspaceTemplate) {
|
||||
if (!document.collectionId) {
|
||||
assertPresent(
|
||||
collectionId,
|
||||
"collectionId is required to publish a draft without collection"
|
||||
@@ -1298,8 +1273,6 @@ router.post(
|
||||
}
|
||||
);
|
||||
authorize(user, "createChildDocument", parentDocument, { collection });
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
authorize(user, "createTemplate", user.team);
|
||||
} else {
|
||||
authorize(user, "createDocument", collection);
|
||||
}
|
||||
@@ -1348,8 +1321,6 @@ router.post(
|
||||
|
||||
if (collection) {
|
||||
authorize(user, "updateDocument", collection);
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
authorize(user, "createTemplate", user.team);
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
@@ -1408,8 +1379,6 @@ router.post(
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "updateDocument", collection);
|
||||
} else if (document.template) {
|
||||
authorize(user, "updateTemplate", user.team);
|
||||
} else if (!parentDocumentId) {
|
||||
throw InvalidRequestError("collectionId is required to move a document");
|
||||
}
|
||||
|
||||
@@ -87,9 +87,6 @@ export const DocumentsListSchema = BaseSchema.extend({
|
||||
/** Id of the parent document to which the document belongs */
|
||||
parentDocumentId: z.string().uuid().nullish(),
|
||||
|
||||
/** Boolean which denotes whether the document is a template */
|
||||
template: z.boolean().optional(),
|
||||
|
||||
/** Document statuses to include in results */
|
||||
statusFilter: z.nativeEnum(StatusFilter).array().optional(),
|
||||
}),
|
||||
|
||||
@@ -41,6 +41,7 @@ import stars from "./stars";
|
||||
import subscriptions from "./subscriptions";
|
||||
import suggestions from "./suggestions";
|
||||
import teams from "./teams";
|
||||
import templates from "./templates";
|
||||
import urls from "./urls";
|
||||
import userMemberships from "./userMemberships";
|
||||
import users from "./users";
|
||||
@@ -93,6 +94,7 @@ router.use("/", stars.routes());
|
||||
router.use("/", subscriptions.routes());
|
||||
router.use("/", suggestions.routes());
|
||||
router.use("/", teams.routes());
|
||||
router.use("/", templates.routes());
|
||||
router.use("/", integrations.routes());
|
||||
router.use("/", notifications.routes());
|
||||
router.use("/", oauthAuthentications.routes());
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./templates";
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
|
||||
import { zodIconType, zodIdType } from "@server/utils/zod";
|
||||
import { ValidateColor } from "@server/validation";
|
||||
|
||||
const TemplatesSortParamsSchema = z.object({
|
||||
/** Specifies the attributes by which templates will be sorted in the list */
|
||||
sort: z
|
||||
.string()
|
||||
.refine((val) =>
|
||||
["createdAt", "updatedAt", "publishedAt", "title"].includes(val)
|
||||
)
|
||||
.default("updatedAt"),
|
||||
|
||||
/** Specifies the sort order with respect to sort field */
|
||||
direction: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||
});
|
||||
|
||||
export const TemplatesListSchema = BaseSchema.extend({
|
||||
body: TemplatesSortParamsSchema.extend({
|
||||
/** Id of the collection to which the template belongs */
|
||||
collectionId: z.string().uuid().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TemplatesCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
collectionId: z.string().uuid().optional(),
|
||||
title: z.string().min(1).max(255),
|
||||
data: ProsemirrorSchema(),
|
||||
icon: zodIconType().nullish(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TemplatesCreateReq = z.infer<typeof TemplatesCreateSchema>;
|
||||
|
||||
export type TemplatesListReq = z.infer<typeof TemplatesListSchema>;
|
||||
|
||||
export const TemplatesInfoSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: zodIdType(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TemplatesInfoReq = z.infer<typeof TemplatesInfoSchema>;
|
||||
|
||||
export const TemplatesDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: zodIdType(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TemplatesDeleteReq = z.infer<typeof TemplatesDeleteSchema>;
|
||||
|
||||
export const TemplatesDuplicateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: zodIdType(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TemplatesDuplicateReq = z.infer<typeof TemplatesDuplicateSchema>;
|
||||
|
||||
export const TemplatesUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: zodIdType(),
|
||||
title: z.string().optional(),
|
||||
data: ProsemirrorSchema().optional(),
|
||||
icon: zodIconType().nullish(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
fullWidth: z.boolean().optional(),
|
||||
collectionId: z.string().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TemplatesUpdateReq = z.infer<typeof TemplatesUpdateSchema>;
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
buildUser,
|
||||
buildTemplate,
|
||||
buildCollection,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#templates.list", () => {
|
||||
it("should list templates", async () => {
|
||||
const user = await buildUser();
|
||||
await buildTemplate(); // create a template that shouldn't be included
|
||||
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(template.id);
|
||||
});
|
||||
|
||||
it("should list templates in collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(template.id);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/templates.list");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#templates.info", () => {
|
||||
it("should return template data", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(template.id);
|
||||
expect(body.data.title).toEqual(template.title);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/templates.info");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should fail for invalid template id", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/templates.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: "invalid",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#templates.update", () => {
|
||||
it("should update template title", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
title: "Original title",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
title: "New title",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual("New title");
|
||||
});
|
||||
|
||||
it("should update template content", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
text: "Original content",
|
||||
});
|
||||
|
||||
const data = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await server.post("/api/templates.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.data).toEqual(data);
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request when id is missing", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/templates.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
title: "New title",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("id: Must be a valid UUID or slug");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/templates.update");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#templates.duplicate", () => {
|
||||
it("should duplicate template", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
title: "test",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.duplicate", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).not.toEqual(template.id);
|
||||
expect(body.data.title).toEqual(template.title);
|
||||
expect(body.data.data).toEqual(template.content);
|
||||
});
|
||||
|
||||
it("should duplicate template with new title", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.duplicate", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
title: "New title",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).not.toEqual(template.id);
|
||||
expect(body.data.title).toEqual("New title");
|
||||
expect(body.data.data).toEqual(template.content);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/templates.duplicate");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should fail for invalid template id", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/templates.duplicate", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: "invalid",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#templates.delete", () => {
|
||||
it("should delete template", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildTemplate({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/templates.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request when id is missing", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/templates.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("id: Must be a valid UUID or slug");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/templates.delete");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import Router from "koa-router";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Collection, Template } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentPolicies, presentTemplate } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"templates.create",
|
||||
auth(),
|
||||
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
|
||||
validate(T.TemplatesCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.TemplatesCreateReq>) => {
|
||||
const { id, title, data, icon, color, collectionId } = ctx.input.body;
|
||||
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
||||
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "createTemplate", user.team);
|
||||
|
||||
let collection;
|
||||
if (collectionId) {
|
||||
collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "createDocument", collection);
|
||||
}
|
||||
|
||||
const template = (await documentCreator({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
content: data,
|
||||
collectionId: collection?.id,
|
||||
publish: true,
|
||||
template: true,
|
||||
user,
|
||||
editorVersion,
|
||||
ctx,
|
||||
})) as unknown as Template;
|
||||
|
||||
ctx.body = {
|
||||
data: presentTemplate(template),
|
||||
policies: presentPolicies(user, [template]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"templates.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.TemplatesListSchema),
|
||||
async (ctx: APIContext<T.TemplatesListReq>) => {
|
||||
const { sort, direction, collectionId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const where: WhereOptions<Template> & {
|
||||
[Op.and]: WhereOptions<Template>[];
|
||||
} = {
|
||||
teamId: user.teamId,
|
||||
[Op.and]: [
|
||||
{
|
||||
deletedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// if a specific collection is passed then we need to check auth to view it
|
||||
if (collectionId) {
|
||||
where[Op.and].push({ collectionId: [collectionId] });
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", collection);
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
collectionId: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
collectionId: await user.collectionIds(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const [templates, total] = await Promise.all([
|
||||
Template.scope([
|
||||
"defaultScope",
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
Template.count({ where }),
|
||||
]);
|
||||
|
||||
const data = await Promise.all(
|
||||
templates.map((template) => presentTemplate(template))
|
||||
);
|
||||
const policies = presentPolicies(user, templates);
|
||||
|
||||
ctx.body = {
|
||||
pagination: { ...ctx.state.pagination, total },
|
||||
data,
|
||||
policies,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"templates.info",
|
||||
auth(),
|
||||
validate(T.TemplatesInfoSchema),
|
||||
async (ctx: APIContext<T.TemplatesInfoReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const template = await Template.findByPk(id, {
|
||||
userId: user.id,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(user, "read", template);
|
||||
|
||||
ctx.body = {
|
||||
data: presentTemplate(template),
|
||||
policies: presentPolicies(user, [template]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"templates.delete",
|
||||
auth(),
|
||||
validate(T.TemplatesDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.TemplatesDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const template = await Template.findByPk(id, {
|
||||
userId: user.id,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "delete", template);
|
||||
|
||||
await template.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"templates.duplicate",
|
||||
auth(),
|
||||
validate(T.TemplatesDuplicateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.TemplatesDuplicateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id, title } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const original = await Template.findByPk(id, {
|
||||
userId: user.id,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "duplicate", original);
|
||||
|
||||
let template = await Template.createWithCtx(ctx, {
|
||||
title: title ?? original.title,
|
||||
createdById: user.id,
|
||||
lastModifiedById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: original.collectionId,
|
||||
content: original.content,
|
||||
icon: original.icon,
|
||||
color: original.color,
|
||||
fullWidth: original.fullWidth,
|
||||
});
|
||||
|
||||
// reload to get all of the data needed to present (user, collection etc)
|
||||
template = await Template.findByPk(template.id, {
|
||||
userId: user.id,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentTemplate(template),
|
||||
policies: presentPolicies(user, [template]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"templates.update",
|
||||
auth(),
|
||||
validate(T.TemplatesUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.TemplatesUpdateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id, data, ...updatedFields } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const template = await Template.findByPk(id, {
|
||||
userId: user.id,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "update", template);
|
||||
|
||||
if (data) {
|
||||
template.content = data;
|
||||
}
|
||||
|
||||
await template.updateWithCtx(ctx, updatedFields);
|
||||
|
||||
ctx.body = {
|
||||
data: presentTemplate(template),
|
||||
policies: presentPolicies(user, [template]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -195,7 +195,8 @@ export const renderShare = async (ctx: Context, next: Next) => {
|
||||
}
|
||||
|
||||
// Allow shares to be embedded in iframes on other websites unless prevented by team preference
|
||||
const preventEmbedding = team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false;
|
||||
const preventEmbedding =
|
||||
team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false;
|
||||
if (!preventEmbedding) {
|
||||
ctx.remove("X-Frame-Options");
|
||||
}
|
||||
|
||||
@@ -93,9 +93,9 @@ export function createDatabaseInstance(
|
||||
|
||||
sequelizeStrictAttributes(instance);
|
||||
return instance;
|
||||
} catch (_err) {
|
||||
} catch (error) {
|
||||
Logger.fatal(
|
||||
"Could not connect to database",
|
||||
env.isDevelopment ? error.message : "Could not connect to database",
|
||||
typeof databaseConfig === "string"
|
||||
? new Error(
|
||||
`Failed to parse: "${databaseConfig}". Ensure special characters in database URL are encoded`
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SearchQuery,
|
||||
Pin,
|
||||
Comment,
|
||||
Template,
|
||||
Import,
|
||||
OAuthAuthorizationCode,
|
||||
OAuthClient,
|
||||
@@ -386,7 +387,9 @@ export async function buildDocument(
|
||||
}
|
||||
|
||||
if (!overrides.userId) {
|
||||
const user = await buildUser();
|
||||
const user = await buildUser({
|
||||
teamId: overrides.teamId,
|
||||
});
|
||||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
@@ -429,6 +432,58 @@ export async function buildDocument(
|
||||
return document;
|
||||
}
|
||||
|
||||
export async function buildTemplate(
|
||||
// Omission first, addition later?
|
||||
// This is actually a workaround to allow
|
||||
// passing collectionId as null. Ideally, it
|
||||
// should be updated in the Document model itself
|
||||
// but that'd cascade and require further changes
|
||||
// beyond the scope of what's required now
|
||||
overrides: Omit<Partial<Template>, "collectionId"> & {
|
||||
userId?: string;
|
||||
text?: string;
|
||||
collectionId?: string | null;
|
||||
} = {}
|
||||
) {
|
||||
if (!overrides.teamId) {
|
||||
const team = await buildTeam();
|
||||
overrides.teamId = team.id;
|
||||
}
|
||||
|
||||
if (!overrides.userId) {
|
||||
const user = await buildUser({
|
||||
teamId: overrides.teamId,
|
||||
});
|
||||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
let collection;
|
||||
if (overrides.collectionId === undefined) {
|
||||
collection = await buildCollection({
|
||||
teamId: overrides.teamId,
|
||||
userId: overrides.userId,
|
||||
});
|
||||
overrides.collectionId = collection.id;
|
||||
}
|
||||
|
||||
const text = overrides.text ?? "This is the text in an example template";
|
||||
const template = await Template.create(
|
||||
{
|
||||
title: faker.lorem.words(4),
|
||||
content: overrides.content ?? parser.parse(text)?.toJSON(),
|
||||
lastModifiedById: overrides.userId,
|
||||
createdById: overrides.userId,
|
||||
editorVersion: "12.0.0",
|
||||
...overrides,
|
||||
},
|
||||
{
|
||||
silent: overrides.createdAt || overrides.updatedAt ? true : false,
|
||||
}
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function buildComment(overrides: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
|
||||
@@ -26,11 +26,9 @@
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
|
||||
"Restore": "Restore",
|
||||
"Collection restored": "Collection restored",
|
||||
"Delete collection": "Delete collection",
|
||||
"Export": "Export",
|
||||
"Export collection": "Export collection",
|
||||
"New document": "New document",
|
||||
"New template": "New template",
|
||||
"Delete collection": "Delete collection",
|
||||
"Delete comment": "Delete comment",
|
||||
"Mark as resolved": "Mark as resolved",
|
||||
"Thread resolved": "Thread resolved",
|
||||
@@ -47,6 +45,7 @@
|
||||
"Debug logging disabled": "Debug logging disabled",
|
||||
"Development": "Development",
|
||||
"Open document": "Open document",
|
||||
"New document": "New document",
|
||||
"New draft": "New draft",
|
||||
"New from template": "New from template",
|
||||
"New nested document": "New nested document",
|
||||
@@ -88,9 +87,7 @@
|
||||
"Create template": "Create template",
|
||||
"Open random document": "Open random document",
|
||||
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
|
||||
"Move to workspace": "Move to workspace",
|
||||
"Move": "Move",
|
||||
"Move to collection": "Move to collection",
|
||||
"Move {{ documentType }}": "Move {{ documentType }}",
|
||||
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
|
||||
"Document archived": "Document archived",
|
||||
@@ -151,6 +148,11 @@
|
||||
"New workspace": "New workspace",
|
||||
"Create a workspace": "Create a workspace",
|
||||
"Login to workspace": "Login to workspace",
|
||||
"New template": "New template",
|
||||
"Template deleted": "Template deleted",
|
||||
"Deleting": "Deleting",
|
||||
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.",
|
||||
"Move template": "Move template",
|
||||
"Invite people": "Invite people",
|
||||
"Invite to workspace": "Invite to workspace",
|
||||
"Promote to {{ role }}": "Promote to {{ role }}",
|
||||
@@ -170,6 +172,7 @@
|
||||
"People": "People",
|
||||
"Share": "Share",
|
||||
"Workspace": "Workspace",
|
||||
"Template": "Template",
|
||||
"Recent searches": "Recent searches",
|
||||
"currently editing": "currently editing",
|
||||
"currently viewing": "currently viewing",
|
||||
@@ -190,7 +193,6 @@
|
||||
"Create": "Create",
|
||||
"Collection deleted": "Collection deleted",
|
||||
"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",
|
||||
@@ -224,11 +226,16 @@
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"No results found": "No results found",
|
||||
"Select a location to move": "Select a location to move",
|
||||
"Document moved": "Document moved",
|
||||
"Couldn’t move the document, try again?": "Couldn’t move the document, try again?",
|
||||
"Move to <em>{{ location }}</em>": "Move to <em>{{ location }}</em>",
|
||||
"Template moved": "Template moved",
|
||||
"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",
|
||||
@@ -434,10 +441,11 @@
|
||||
"Installation": "Installation",
|
||||
"Unstar document": "Unstar document",
|
||||
"Star document": "Star document",
|
||||
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents",
|
||||
"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",
|
||||
"Location": "Location",
|
||||
"Visibility": "Visibility",
|
||||
"Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.",
|
||||
"Editors can create, edit, and comment on documents.": "Editors can create, edit, and comment on documents.",
|
||||
"Viewers can only view and comment on documents.": "Viewers can only view and comment on documents.",
|
||||
@@ -604,6 +612,7 @@
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Contents": "Contents",
|
||||
"Table of contents": "Table of contents",
|
||||
"Template options": "Template options",
|
||||
"Change name": "Change name",
|
||||
"Change email": "Change email",
|
||||
"Suspend user": "Suspend user",
|
||||
@@ -613,7 +622,6 @@
|
||||
"Revoke invite": "Revoke invite",
|
||||
"Activate user": "Activate user",
|
||||
"User options": "User options",
|
||||
"template": "template",
|
||||
"document": "document",
|
||||
"published": "published",
|
||||
"edited": "edited",
|
||||
@@ -725,23 +733,15 @@
|
||||
"Sorry, the last change could not be persisted – please reload the page": "Sorry, the last change could not be persisted – please reload the page",
|
||||
"{{ count }} days": "{{ count }} day",
|
||||
"{{ count }} days_plural": "{{ count }} days",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
|
||||
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents",
|
||||
"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.",
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"Last accessed": "Last accessed",
|
||||
"Domain": "Domain",
|
||||
"Views": "Views",
|
||||
"Updated by": "Updated by",
|
||||
"All roles": "All roles",
|
||||
"Editors": "Editors",
|
||||
"All status": "All status",
|
||||
@@ -1167,9 +1168,9 @@
|
||||
"Sharing is currently disabled.": "Sharing is currently disabled.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
|
||||
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
|
||||
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
|
||||
"Alphabetical": "Alphabetical",
|
||||
"There are no templates just yet.": "There are no templates just yet.",
|
||||
"Could not load templates": "Could not load templates",
|
||||
"Templates help your team create consistent and accurate documentation.": "Templates help your team create consistent and accurate documentation.",
|
||||
"No templates have been created yet": "No templates have been created yet",
|
||||
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
|
||||
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.",
|
||||
"Confirmation code": "Confirmation code",
|
||||
|
||||
Reference in New Issue
Block a user