Compare commits

...

6 Commits

Author SHA1 Message Date
Tom Moor 1b5e1add7b Merge main 2024-07-24 23:39:27 -04:00
hmacr 43c99f4033 review 2024-06-27 04:10:02 +05:30
hmacr 943ec40ab0 tiny refactors 2024-06-26 03:51:44 +05:30
hmacr ef8d4f7236 policy fixes 2024-06-26 03:27:04 +05:30
hmacr dd6326a512 show templates page to team admins and members 2024-06-26 03:18:20 +05:30
hmacr 0edff84ed1 feat: Workspace templates 2024-06-26 03:18:20 +05:30
32 changed files with 972 additions and 211 deletions
+84 -6
View File
@@ -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(),
+22
View File
@@ -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;
+98
View File
@@ -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);
+1 -1
View File
@@ -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,
},
+18 -5
View File
@@ -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),
{
+32 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+6 -3
View File
@@ -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>
)}
+13 -4
View File
@@ -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();
+2 -2
View File
@@ -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"));
+1 -1
View File
@@ -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;
+4 -1
View File
@@ -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) {
+22 -7
View File
@@ -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 {
+4 -2
View File
@@ -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(
+3 -6
View File
@@ -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,
-5
View File
@@ -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;
+1 -1
View File
@@ -106,7 +106,7 @@ export default async function documentUpdater({
ip,
};
if (publish && cId) {
if (publish && (document.template || cId)) {
if (!document.collectionId) {
document.collectionId = cId;
}
+11 -4
View File
@@ -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
View File
@@ -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?"
+65 -1
View File
@@ -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);
});
}
});
});
+17
View File
@@ -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)
)
);
+243 -22
View File
@@ -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 });
+47 -11
View File
@@ -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;
+14 -17
View File
@@ -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>;
+9 -3
View File
@@ -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",