mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b5e1add7b | |||
| 43c99f4033 | |||
| 943ec40ab0 | |||
| ef8d4f7236 | |||
| dd6326a512 | |||
| 0edff84ed1 |
@@ -37,10 +37,10 @@ import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection, TrashSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
@@ -223,7 +223,7 @@ export const publishDocument = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
if (document?.collectionId || document?.template) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -688,7 +688,7 @@ export const createTemplateFromDocument = createAction({
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -735,11 +735,50 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.move({
|
||||
collectionId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document?.template && document?.collectionId
|
||||
? t("Move to collection")
|
||||
: t("Move");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -763,6 +802,44 @@ export const moveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentButton = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveDocumentMenu = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the menu if this is not a template (or) a workspace template.
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Archive document",
|
||||
@@ -997,7 +1074,8 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [document, history, t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Create template")}
|
||||
savingText={`${t("Creating")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="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."
|
||||
values={{
|
||||
titleWithDefault: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentTemplatizeDialog);
|
||||
@@ -100,7 +100,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move(id, collection.id);
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
|
||||
@@ -52,7 +52,11 @@ function CollectionLinkChildren({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
void documents.move(item.id, collection.id, undefined, 0);
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
|
||||
@@ -187,7 +187,11 @@ function InnerDocumentLink(
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
await documents.move(item.id, collection.id, node.id);
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
@@ -249,11 +253,21 @@ function InnerDocumentLink(
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
void documents.move(item.id, collection.id, node.id, 0);
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
index: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void documents.move(item.id, collection.id, parentId, index + 1);
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: parentId,
|
||||
index: index + 1,
|
||||
});
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
|
||||
<Flex align="center" gap={4}>
|
||||
<IconWrapper>{icon}</IconWrapper>
|
||||
{value}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default Label;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect, { Option } from "~/components/InputSelect";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Label from "./Label";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
defaultCollectionId?: string | null;
|
||||
onSelect: (collectionId: string | null) => void;
|
||||
};
|
||||
|
||||
const SelectLocation = ({ id, defaultCollectionId, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
|
||||
const workspaceOption: Option | null = can.createDocument
|
||||
? {
|
||||
label: (
|
||||
<Label
|
||||
icon={<TeamLogo model={team} size={AvatarSize.Toast} />}
|
||||
value={t("Workspace")}
|
||||
/>
|
||||
),
|
||||
value: "workspace",
|
||||
}
|
||||
: null;
|
||||
|
||||
const collectionOptions = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce<Option[]>((options, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
if (can.createDocument) {
|
||||
options.push({
|
||||
label: (
|
||||
<Label
|
||||
icon={<CollectionIcon collection={collection} />}
|
||||
value={collection.name}
|
||||
/>
|
||||
),
|
||||
value: collection.id,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, []),
|
||||
[collections.orderedData, policies]
|
||||
);
|
||||
|
||||
const options = workspaceOption
|
||||
? collectionOptions.length
|
||||
? [
|
||||
workspaceOption,
|
||||
...collectionOptions.map((opt, idx) => {
|
||||
if (idx !== 0) {
|
||||
return opt;
|
||||
}
|
||||
opt.divider = true;
|
||||
return opt;
|
||||
}),
|
||||
]
|
||||
: [workspaceOption]
|
||||
: collectionOptions;
|
||||
|
||||
const handleSelection = React.useCallback(
|
||||
(value: string | null) => {
|
||||
onSelect(value === "workspace" ? null : value);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
if (fetching || !options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSelect
|
||||
id={id}
|
||||
value={defaultCollectionId ?? "workspace"}
|
||||
options={options}
|
||||
onChange={handleSelection}
|
||||
ariaLabel={t("Location")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledSelect = styled(InputSelect)`
|
||||
width: 220px;
|
||||
`;
|
||||
|
||||
export default SelectLocation;
|
||||
@@ -0,0 +1,98 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import SelectLocation from "./SelectLocation";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const [publish, setPublish] = React.useState(true);
|
||||
const [collectionId, setCollectionId] = React.useState(
|
||||
document.collectionId ?? null
|
||||
);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize({
|
||||
collectionId,
|
||||
publish,
|
||||
});
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [t, document, history, collectionId, publish]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Create template")}
|
||||
savingText={`${t("Creating")}…`}
|
||||
>
|
||||
<Flex column gap={12}>
|
||||
<div>
|
||||
<Trans
|
||||
defaults="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."
|
||||
values={{
|
||||
titleWithDefault: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text>
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Published")}
|
||||
note={t(
|
||||
"Create a published template that's available for use immediately."
|
||||
)}
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
<Flex justify="space-between">
|
||||
<Location>
|
||||
<label htmlFor={"templateLocation"}>{t("Location")}</label>
|
||||
</Location>
|
||||
<SelectLocation
|
||||
id={"templateLocation"}
|
||||
defaultCollectionId={collectionId}
|
||||
onSelect={setCollectionId}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const Location = styled(Text)`
|
||||
margin-top: 3px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentTemplatizeDialog);
|
||||
@@ -139,7 +139,7 @@ const useSettingsConfig = () => {
|
||||
name: t("Templates"),
|
||||
path: settingsPath("templates"),
|
||||
component: Templates,
|
||||
enabled: can.update,
|
||||
enabled: can.readDocument,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
createTemplateFromDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
moveDocument,
|
||||
moveDocumentButton,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
downloadDocument,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
moveDocumentMenu,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -124,7 +126,11 @@ function DocumentMenu({
|
||||
}
|
||||
) => {
|
||||
await document.restore(options);
|
||||
toast.success(t("Document restored"));
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
[t, document]
|
||||
);
|
||||
@@ -228,7 +234,10 @@ function DocumentMenu({
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) &&
|
||||
can.restore) ||
|
||||
can.unarchive,
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
@@ -236,7 +245,10 @@ function DocumentMenu({
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!collection && !!can.restore && restoreItems.length !== 0,
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
@@ -289,7 +301,8 @@ function DocumentMenu({
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveDocumentButton, context),
|
||||
actionToMenuItem(moveDocumentMenu, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Header from "~/components/ContextMenu/Header";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -28,7 +28,16 @@ function NewTemplateMenu() {
|
||||
});
|
||||
}, [collections]);
|
||||
|
||||
const items = React.useMemo(
|
||||
const workspaceItem: MenuItem | null = can.createDocument
|
||||
? {
|
||||
type: "route",
|
||||
to: newTemplatePath(),
|
||||
title: t("Save in workspace"),
|
||||
icon: <TeamLogo model={team} />,
|
||||
}
|
||||
: null;
|
||||
|
||||
const collectionItems = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -47,6 +56,27 @@ function NewTemplateMenu() {
|
||||
[collections.orderedData, policies]
|
||||
);
|
||||
|
||||
const collectionItemsWithHeader: MenuItem[] = React.useMemo(
|
||||
() =>
|
||||
collectionItems.length
|
||||
? [
|
||||
{ type: "heading", title: t("Choose a collection") },
|
||||
...collectionItems,
|
||||
]
|
||||
: [],
|
||||
[t, collectionItems]
|
||||
);
|
||||
|
||||
const items = workspaceItem
|
||||
? collectionItemsWithHeader.length
|
||||
? [
|
||||
workspaceItem,
|
||||
{ type: "separator" } as MenuItem,
|
||||
...collectionItemsWithHeader,
|
||||
]
|
||||
: [workspaceItem]
|
||||
: collectionItemsWithHeader;
|
||||
|
||||
if (!can.createDocument || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +91,6 @@ function NewTemplateMenu() {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={t("New template")} {...menu}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
|
||||
+49
-33
@@ -6,11 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { replaceTitleVariables } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
@@ -25,36 +25,56 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const templates = documents.templates;
|
||||
|
||||
if (!templates.length) {
|
||||
const templateToMenuItem = React.useCallback(
|
||||
(tmpl: Document): MenuItem => ({
|
||||
type: "button",
|
||||
title: replaceTitleVariables(tmpl.titleWithDefault, user),
|
||||
icon: tmpl.icon ? (
|
||||
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
onClick: () => onSelectTemplate(tmpl),
|
||||
}),
|
||||
[user, onSelectTemplate]
|
||||
);
|
||||
|
||||
const templates = documents.templates.filter((tmpl) => tmpl.publishedAt);
|
||||
|
||||
const collectionItems = templates
|
||||
.filter(
|
||||
(tmpl) =>
|
||||
!tmpl.isWorkspaceTemplate && tmpl.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceTemplates = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceItems: MenuItem[] = React.useMemo(
|
||||
() =>
|
||||
workspaceTemplates.length
|
||||
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
|
||||
: [],
|
||||
[t, workspaceTemplates]
|
||||
);
|
||||
|
||||
const items = collectionItems
|
||||
? workspaceItems.length
|
||||
? [
|
||||
...collectionItems,
|
||||
{ type: "separator" } as MenuItem,
|
||||
...workspaceItems,
|
||||
]
|
||||
: collectionItems
|
||||
: workspaceItems;
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templatesInCollection = templates.filter(
|
||||
(t) => t.collectionId === document.collectionId
|
||||
);
|
||||
const otherTemplates = templates.filter(
|
||||
(t) => t.collectionId !== document.collectionId
|
||||
);
|
||||
|
||||
const renderTemplate = (template: Document) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => onSelectTemplate(template)}
|
||||
icon={
|
||||
template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
)
|
||||
}
|
||||
{...menu}
|
||||
>
|
||||
{replaceTitleVariables(template.titleWithDefault, user)}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
@@ -65,11 +85,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Templates")}>
|
||||
{templatesInCollection.map(renderTemplate)}
|
||||
{otherTemplates.length && templatesInCollection.length ? (
|
||||
<Separator />
|
||||
) : undefined}
|
||||
{otherTemplates.map(renderTemplate)}
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
+16
-3
@@ -381,6 +381,11 @@ export default class Document extends ParanoidModel {
|
||||
return this.collection?.pathToDocument(this.id) ?? [];
|
||||
}
|
||||
|
||||
@computed
|
||||
get isWorkspaceTemplate() {
|
||||
return this.template && !this.collectionId;
|
||||
}
|
||||
|
||||
get titleWithDefault(): string {
|
||||
return this.title || i18n.t("Untitled");
|
||||
}
|
||||
@@ -490,7 +495,13 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = () => this.store.templatize(this.id);
|
||||
templatize = ({
|
||||
collectionId,
|
||||
publish,
|
||||
}: {
|
||||
collectionId: string | null;
|
||||
publish: boolean;
|
||||
}) => this.store.templatize({ id: this.id, collectionId, publish });
|
||||
|
||||
@action
|
||||
save = async (
|
||||
@@ -517,8 +528,10 @@ export default class Document extends ParanoidModel {
|
||||
}
|
||||
};
|
||||
|
||||
move = (collectionId: string, parentDocumentId?: string | undefined) =>
|
||||
this.store.move(this.id, collectionId, parentDocumentId);
|
||||
move = (options: {
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}) => this.store.move({ documentId: this.id, ...options });
|
||||
|
||||
duplicate = (options?: {
|
||||
title?: string;
|
||||
|
||||
@@ -177,7 +177,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) {
|
||||
if (!can.update && isEditRoute && !document.template) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,9 @@ function DocumentHeader({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const can = usePolicy(document);
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const isShare = !!shareId;
|
||||
const showContents =
|
||||
@@ -276,7 +277,7 @@ function DocumentHeader({
|
||||
<ShareButton document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{(isEditing || isTemplate) && (
|
||||
{(isEditing || isTemplateEditable) && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
@@ -351,7 +352,9 @@ function DocumentHeader({
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
>
|
||||
{document.collectionId ? t("Publish") : `${t("Publish")}…`}
|
||||
{document.collectionId || document.isWorkspaceTemplate
|
||||
? t("Publish")
|
||||
: `${t("Publish")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,11 @@ import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { collectionPath, documentPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
collectionPath,
|
||||
documentPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -21,7 +25,8 @@ 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;
|
||||
const canArchive =
|
||||
!document.isDraft && !document.isArchived && !document.template;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -50,8 +55,12 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
history.push(collectionPath(collection?.path || "/"));
|
||||
// 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);
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
|
||||
@@ -68,9 +68,9 @@ function DocumentMove({ document }: Props) {
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
|
||||
if (type === "document") {
|
||||
await document.move(collectionId, parentDocumentId);
|
||||
await document.move({ collectionId, parentDocumentId });
|
||||
} else {
|
||||
await document.move(collectionId);
|
||||
await document.move({ collectionId });
|
||||
}
|
||||
|
||||
toast.success(t("Document moved"));
|
||||
|
||||
@@ -50,7 +50,7 @@ function DocumentPublish({ document }: Props) {
|
||||
|
||||
// Also move it under if selected path corresponds to another doc
|
||||
if (type === "document") {
|
||||
await document.move(collectionId, parentDocumentId);
|
||||
await document.move({ collectionId, parentDocumentId });
|
||||
}
|
||||
|
||||
document.collectionId = collectionId;
|
||||
|
||||
@@ -48,7 +48,10 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await documents.move(item.id, collection.id);
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
toast.message(t("Document moved"));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -457,7 +457,15 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = async (id: string): Promise<Document | null | undefined> => {
|
||||
templatize = async ({
|
||||
id,
|
||||
collectionId,
|
||||
publish,
|
||||
}: {
|
||||
id: string;
|
||||
collectionId: string | null;
|
||||
publish: boolean;
|
||||
}): Promise<Document | null | undefined> => {
|
||||
const doc: Document | null | undefined = this.data.get(id);
|
||||
invariant(doc, "Document should exist");
|
||||
|
||||
@@ -467,6 +475,8 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
const res = await client.post("/documents.templatize", {
|
||||
id,
|
||||
collectionId,
|
||||
publish,
|
||||
});
|
||||
invariant(res?.data, "Document not available");
|
||||
this.addPolicies(res.policies);
|
||||
@@ -546,12 +556,17 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
move = async (
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
parentDocumentId?: string | null,
|
||||
index?: number | null
|
||||
) => {
|
||||
move = async ({
|
||||
documentId,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
index,
|
||||
}: {
|
||||
documentId: string;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string | null;
|
||||
index?: number | null;
|
||||
}) => {
|
||||
this.movingDocumentId = documentId;
|
||||
|
||||
try {
|
||||
|
||||
@@ -81,8 +81,10 @@ export function updateDocumentPath(oldUrl: string, document: Document): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function newTemplatePath(collectionId: string) {
|
||||
return settingsPath("templates") + `/new?collectionId=${collectionId}`;
|
||||
export function newTemplatePath(collectionId?: string) {
|
||||
return collectionId
|
||||
? settingsPath("templates") + `/new?collectionId=${collectionId}`
|
||||
: `${settingsPath("templates")}/new`;
|
||||
}
|
||||
|
||||
export function newDocumentPath(
|
||||
|
||||
@@ -148,14 +148,12 @@ export default async function documentCreator({
|
||||
);
|
||||
|
||||
if (publish) {
|
||||
if (!collectionId) {
|
||||
if (!collectionId && !template) {
|
||||
throw new Error("Collection ID is required to publish");
|
||||
}
|
||||
|
||||
await document.publish(user, collectionId, {
|
||||
silent: true,
|
||||
transaction,
|
||||
});
|
||||
await document.publish(user, collectionId, { silent: true, transaction });
|
||||
|
||||
if (document.title) {
|
||||
await Event.create(
|
||||
{
|
||||
@@ -168,7 +166,6 @@ export default async function documentCreator({
|
||||
source: importId ? "import" : undefined,
|
||||
title: document.title,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import {
|
||||
User,
|
||||
@@ -58,10 +57,6 @@ async function documentMover({
|
||||
}
|
||||
|
||||
if (document.template) {
|
||||
if (!document.collectionId) {
|
||||
throw ValidationError("Templates must be in a collection");
|
||||
}
|
||||
|
||||
document.collectionId = collectionId;
|
||||
document.parentDocumentId = null;
|
||||
document.lastModifiedById = user.id;
|
||||
|
||||
@@ -106,7 +106,7 @@ export default async function documentUpdater({
|
||||
ip,
|
||||
};
|
||||
|
||||
if (publish && cId) {
|
||||
if (publish && (document.template || cId)) {
|
||||
if (!document.collectionId) {
|
||||
document.collectionId = cId;
|
||||
}
|
||||
|
||||
@@ -722,6 +722,13 @@ class Document extends ParanoidModel<
|
||||
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.
|
||||
*
|
||||
@@ -817,10 +824,10 @@ class Document extends ParanoidModel<
|
||||
|
||||
publish = async (
|
||||
user: User,
|
||||
collectionId: string,
|
||||
options: SaveOptions
|
||||
collectionId?: string | null,
|
||||
options?: SaveOptions
|
||||
): Promise<this> => {
|
||||
const { transaction } = options;
|
||||
const transaction = options?.transaction;
|
||||
|
||||
// If the document is already published then calling publish should act like
|
||||
// a regular save
|
||||
@@ -832,7 +839,7 @@ class Document extends ParanoidModel<
|
||||
this.collectionId = collectionId;
|
||||
}
|
||||
|
||||
if (!this.template) {
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
|
||||
+42
-15
@@ -5,20 +5,10 @@ import {
|
||||
DocumentPermission,
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { Document, Revision, User, Team } from "@server/models";
|
||||
import { Document, Revision, User } from "@server/models";
|
||||
import { allow, _cannot as cannot, _can as can } from "./cancan";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, "createDocument", Team, (actor, document) =>
|
||||
and(
|
||||
//
|
||||
!actor.isGuest,
|
||||
!actor.isViewer,
|
||||
isTeamModel(actor, document),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "read", Document, (actor, document) =>
|
||||
and(
|
||||
isTeamModel(actor, document),
|
||||
@@ -29,6 +19,10 @@ allow(User, "read", Document, (actor, document) =>
|
||||
DocumentPermission.Admin,
|
||||
]),
|
||||
and(!!document?.isDraft, actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
can(actor, "readDocument", actor.team)
|
||||
),
|
||||
can(actor, "readDocument", document?.collection)
|
||||
)
|
||||
)
|
||||
@@ -98,7 +92,14 @@ allow(User, "update", Document, (actor, document) =>
|
||||
]),
|
||||
or(
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById)
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateDocument", actor.team)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -118,7 +119,14 @@ allow(User, ["manageUsers", "duplicate"], Document, (actor, document) =>
|
||||
or(
|
||||
includesMembership(document, [DocumentPermission.Admin]),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
!!document?.isDraft && actor.id === document?.createdById
|
||||
!!document?.isDraft && actor.id === document?.createdById,
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateDocument", actor.team)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -128,7 +136,14 @@ allow(User, "move", Document, (actor, document) =>
|
||||
can(actor, "update", document),
|
||||
or(
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById)
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateDocument", actor.team)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -166,7 +181,7 @@ allow(User, "delete", Document, (actor, document) =>
|
||||
or(
|
||||
can(actor, "unarchive", document),
|
||||
can(actor, "update", document),
|
||||
!document?.collection
|
||||
and(!document?.isWorkspaceTemplate, !document?.collection)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -183,6 +198,10 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
|
||||
]),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
can(actor, "updateDocument", actor.team)
|
||||
),
|
||||
!document?.collection
|
||||
)
|
||||
)
|
||||
@@ -236,6 +255,14 @@ allow(User, "unpublish", Document, (user, document) => {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
document.isWorkspaceTemplate &&
|
||||
(user.id === document.createdById || can(user, "updateDocument", user.team))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { UserRole } from "@shared/types";
|
||||
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
|
||||
import { setSelfHosted } from "@server/test/support";
|
||||
import { serialize } from "./index";
|
||||
|
||||
describe.skip("policies/team", () => {
|
||||
describe("policies/team", () => {
|
||||
it("should allow reading only", async () => {
|
||||
setSelfHosted();
|
||||
|
||||
@@ -51,4 +52,67 @@ describe.skip("policies/team", () => {
|
||||
expect(abilities.createGroup).toEqual(true);
|
||||
expect(abilities.createIntegration).toEqual(true);
|
||||
});
|
||||
|
||||
describe("read document", () => {
|
||||
const permissions = new Map<UserRole, boolean>([
|
||||
[UserRole.Admin, true],
|
||||
[UserRole.Member, true],
|
||||
[UserRole.Viewer, false],
|
||||
[UserRole.Guest, false],
|
||||
]);
|
||||
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.readDocument).toEqual(permission);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("create document", () => {
|
||||
const permissions = new Map<UserRole, boolean>([
|
||||
[UserRole.Admin, true],
|
||||
[UserRole.Member, true],
|
||||
[UserRole.Viewer, false],
|
||||
[UserRole.Guest, false],
|
||||
]);
|
||||
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.createDocument).toEqual(permission);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("update document", () => {
|
||||
const permissions = new Map<UserRole, boolean>([
|
||||
[UserRole.Admin, true],
|
||||
[UserRole.Member, false],
|
||||
[UserRole.Viewer, false],
|
||||
[UserRole.Guest, false],
|
||||
]);
|
||||
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.updateDocument).toEqual(permission);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,3 +32,20 @@ allow(User, ["delete", "audit"], Team, (actor, team) =>
|
||||
isTeamAdmin(actor, team)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["createDocument", "readDocument"], Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
!actor.isGuest,
|
||||
!actor.isViewer,
|
||||
isTeamModel(actor, team)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "updateDocument", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
actor.isAdmin,
|
||||
isTeamModel(actor, team)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1929,6 +1929,140 @@ describe("#documents.templatize", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.message).toBe("id: Required");
|
||||
});
|
||||
it("should require publish", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.templatize", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: "random-id",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.message).toBe("publish: Required");
|
||||
});
|
||||
it("should create a published non-workspace template", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.templatize", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
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 collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.templatize", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
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({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.templatize", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
publish: false,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.publishedAt).toBeNull();
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
it("should create a draft workspace template", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.templatize", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
publish: false,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
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({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const anotherCollection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.templatize", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: anotherCollection.id,
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.publishedAt).toBeTruthy();
|
||||
expect(body.data.collectionId).toEqual(anotherCollection.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.archived", () => {
|
||||
@@ -2285,23 +2419,6 @@ describe("#documents.move", () => {
|
||||
expect(body.message).toEqual("id: Required");
|
||||
});
|
||||
|
||||
it("should require collectionId", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/documents.move", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("collectionId: Required");
|
||||
});
|
||||
|
||||
it("should fail for invalid index", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
@@ -2372,6 +2489,29 @@ describe("#documents.move", () => {
|
||||
expect(body.policies[0].abilities.move).toEqual(true);
|
||||
});
|
||||
|
||||
it("should move the template without collectionId", async () => {
|
||||
const user = await buildUser();
|
||||
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).toEqual(true);
|
||||
});
|
||||
|
||||
it("should not allow moving the document to a collection the user cannot access", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
@@ -2413,6 +2553,54 @@ describe("#documents.move", () => {
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should move a template to workspace", async () => {
|
||||
const user = await buildUser();
|
||||
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,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.documents[0].collectionId).toBeNull();
|
||||
expect(body.policies[0].abilities.move).toEqual(true);
|
||||
});
|
||||
|
||||
it("should move a workspace template to collection", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
});
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/documents.move", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
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).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.restore", () => {
|
||||
@@ -2858,7 +3046,7 @@ describe("#documents.create", () => {
|
||||
expect(body.data.collectionId).toBeNull();
|
||||
});
|
||||
|
||||
it("should not allow creating a template with a collection", async () => {
|
||||
it("should allow creating a draft template without a collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const res = await server.post("/api/documents.create", {
|
||||
@@ -2871,10 +3059,10 @@ describe("#documents.create", () => {
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toBe(
|
||||
"collectionId is required to create a template document"
|
||||
);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.template).toBe(true);
|
||||
expect(body.data.publishedAt).toBeNull();
|
||||
expect(body.data.collectionId).toBeNull();
|
||||
});
|
||||
|
||||
it("should not allow publishing without specifying the collection", async () => {
|
||||
@@ -3094,6 +3282,39 @@ 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 });
|
||||
|
||||
@@ -43,12 +43,13 @@ import {
|
||||
User,
|
||||
View,
|
||||
UserMembership,
|
||||
Team,
|
||||
} from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import { authorize, cannot } from "@server/policies";
|
||||
import { authorize, can, cannot } from "@server/policies";
|
||||
import {
|
||||
presentCollection,
|
||||
presentDocument,
|
||||
@@ -129,7 +130,15 @@ router.post(
|
||||
} // otherwise, filter by all collections the user has access to
|
||||
} else {
|
||||
const collectionIds = await user.collectionIds();
|
||||
where = { ...where, collectionId: collectionIds };
|
||||
where = {
|
||||
...where,
|
||||
collectionId:
|
||||
template && can(user, "readDocument", user.team)
|
||||
? {
|
||||
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
|
||||
}
|
||||
: collectionIds,
|
||||
};
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
@@ -915,7 +924,7 @@ router.post(
|
||||
validate(T.DocumentsTemplatizeSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { id, collectionId, publish } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
@@ -926,12 +935,21 @@ router.post(
|
||||
|
||||
authorize(user, "update", original);
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, { transaction });
|
||||
authorize(user, "createDocument", collection);
|
||||
} else {
|
||||
authorize(user, "createDocument", user.team);
|
||||
}
|
||||
|
||||
const document = await Document.create(
|
||||
{
|
||||
editorVersion: original.editorVersion,
|
||||
collectionId: original.collectionId,
|
||||
collectionId,
|
||||
teamId: original.teamId,
|
||||
publishedAt: new Date(),
|
||||
publishedAt: publish ? new Date() : null,
|
||||
lastModifiedById: user.id,
|
||||
createdById: user.id,
|
||||
template: true,
|
||||
@@ -1007,7 +1025,7 @@ router.post(
|
||||
authorize(user, "publish", document);
|
||||
}
|
||||
|
||||
if (!document.collectionId) {
|
||||
if (!document.collectionId && !document.isWorkspaceTemplate) {
|
||||
assertPresent(
|
||||
collectionId,
|
||||
"collectionId is required to publish a draft without collection"
|
||||
@@ -1026,6 +1044,9 @@ router.post(
|
||||
}
|
||||
);
|
||||
authorize(user, "createChildDocument", parentDocument, { collection });
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
authorize(user, "createDocument", team);
|
||||
} else {
|
||||
authorize(user, "createDocument", collection);
|
||||
}
|
||||
@@ -1076,6 +1097,8 @@ router.post(
|
||||
|
||||
if (collection) {
|
||||
authorize(user, "updateDocument", collection);
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
authorize(user, "createDocument", user.team);
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
@@ -1128,10 +1151,21 @@ router.post(
|
||||
});
|
||||
authorize(user, "move", document);
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, { transaction });
|
||||
authorize(user, "updateDocument", collection);
|
||||
invariant(
|
||||
collectionId || document.template,
|
||||
"collectionId is required to move a document"
|
||||
);
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, { transaction });
|
||||
authorize(user, "updateDocument", collection);
|
||||
}
|
||||
|
||||
if (document.isWorkspaceTemplate) {
|
||||
authorize(user, "updateDocument", user.team);
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
const parent = await Document.findByPk(parentDocumentId, {
|
||||
@@ -1148,7 +1182,7 @@ router.post(
|
||||
const { documents, collections, collectionChanged } = await documentMover({
|
||||
user,
|
||||
document,
|
||||
collectionId,
|
||||
collectionId: collectionId ?? null,
|
||||
parentDocumentId,
|
||||
index,
|
||||
ip: ctx.request.ip,
|
||||
@@ -1427,6 +1461,8 @@ router.post(
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "createDocument", collection);
|
||||
} else if (!!template && !collectionId) {
|
||||
authorize(user, "createDocument", user.team);
|
||||
}
|
||||
|
||||
let templateDocument: Document | null | undefined;
|
||||
|
||||
@@ -196,7 +196,12 @@ export const DocumentsDuplicateSchema = BaseSchema.extend({
|
||||
export type DocumentsDuplicateReq = z.infer<typeof DocumentsDuplicateSchema>;
|
||||
|
||||
export const DocumentsTemplatizeSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema,
|
||||
body: BaseIdSchema.extend({
|
||||
/** Id of the collection inside which the template should be created */
|
||||
collectionId: z.string().nullish(),
|
||||
/** Whether the new template should be published */
|
||||
publish: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
|
||||
@@ -259,7 +264,7 @@ export type DocumentsUpdateReq = z.infer<typeof DocumentsUpdateSchema>;
|
||||
export const DocumentsMoveSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema.extend({
|
||||
/** Id of collection to which the doc is supposed to be moved */
|
||||
collectionId: z.string().uuid(),
|
||||
collectionId: z.string().uuid().nullish(),
|
||||
|
||||
/** Parent Id, in case if the doc is moved to a new parent */
|
||||
parentDocumentId: z.string().uuid().nullish(),
|
||||
@@ -364,21 +369,13 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
||||
/** Whether this should be considered a template */
|
||||
template: z.boolean().optional(),
|
||||
}),
|
||||
})
|
||||
.refine((req) => !(req.body.template && !req.body.collectionId), {
|
||||
message: "collectionId is required to create a template document",
|
||||
})
|
||||
.refine(
|
||||
(req) =>
|
||||
!(
|
||||
req.body.publish &&
|
||||
!req.body.parentDocumentId &&
|
||||
!req.body.collectionId
|
||||
),
|
||||
{
|
||||
message: "collectionId or parentDocumentId is required to publish",
|
||||
}
|
||||
);
|
||||
}).refine(
|
||||
(req) =>
|
||||
!(req.body.publish && !req.body.parentDocumentId && !req.body.collectionId),
|
||||
{
|
||||
message: "collectionId or parentDocumentId is required to publish",
|
||||
}
|
||||
);
|
||||
|
||||
export type DocumentsCreateReq = z.infer<typeof DocumentsCreateSchema>;
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@
|
||||
"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 }}",
|
||||
"Archive": "Archive",
|
||||
"Document archived": "Document archived",
|
||||
@@ -200,8 +202,6 @@
|
||||
"{{ completed }} task done": "{{ completed }} task done",
|
||||
"{{ completed }} task done_plural": "{{ completed }} tasks done",
|
||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||
"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.",
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
|
||||
@@ -350,6 +350,10 @@
|
||||
"No results": "No results",
|
||||
"Previous page": "Previous page",
|
||||
"Next page": "Next page",
|
||||
"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.",
|
||||
"Create a published template that's available for use immediately.": "Create a published template that's available for use immediately.",
|
||||
"Location": "Location",
|
||||
"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.",
|
||||
@@ -472,7 +476,7 @@
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Comment options": "Comment options",
|
||||
"Document restored": "Document restored",
|
||||
"{{ documentName }} restored": "{{ documentName }} restored",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
"Choose a collection": "Choose a collection",
|
||||
@@ -484,6 +488,7 @@
|
||||
"Member options": "Member options",
|
||||
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
|
||||
"New child document": "New child document",
|
||||
"Save in workspace": "Save in workspace",
|
||||
"Notification settings": "Notification settings",
|
||||
"Revision options": "Revision options",
|
||||
"Share link revoked": "Share link revoked",
|
||||
@@ -558,6 +563,7 @@
|
||||
"No resolved comments": "No resolved comments",
|
||||
"No comments yet": "No comments yet",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Document restored": "Document restored",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
"{{ count }} comment": "{{ count }} comment",
|
||||
"{{ count }} comment_plural": "{{ count }} comments",
|
||||
|
||||
Reference in New Issue
Block a user