diff --git a/app/components/Breadcrumb.js b/app/components/Breadcrumb.js
index 4e7c521c..350e20ad 100644
--- a/app/components/Breadcrumb.js
+++ b/app/components/Breadcrumb.js
@@ -1,193 +1,87 @@
// @flow
-import { observer } from "mobx-react";
-import {
- ArchiveIcon,
- EditIcon,
- GoToIcon,
- ShapesIcon,
- TrashIcon,
-} from "outline-icons";
+import { GoToIcon } from "outline-icons";
import * as React from "react";
-import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
-import Document from "models/Document";
-import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
-import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
-import { collectionUrl } from "utils/routeHelpers";
-type Props = {|
- document: Document,
- children?: React.Node,
- onlyText: boolean,
+type MenuItem = {|
+ icon?: React.Node,
+ title: React.Node,
+ to?: string,
|};
-function Icon({ document }) {
- const { t } = useTranslation();
+type Props = {|
+ items: MenuItem[],
+ max?: number,
+ children?: React.Node,
+ highlightFirstItem?: boolean,
+|};
- if (document.isDeleted) {
- return (
- <>
-
-
-
- {t("Trash")}
-
-
- >
- );
- }
- if (document.isArchived) {
- return (
- <>
-
-
-
- {t("Archive")}
-
-
- >
- );
- }
- if (document.isDraft) {
- return (
- <>
-
-
-
- {t("Drafts")}
-
-
- >
- );
- }
- if (document.isTemplate) {
- return (
- <>
-
-
-
- {t("Templates")}
-
-
- >
- );
- }
- return null;
-}
+function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
+ const totalItems = items.length;
+ let topLevelItems: MenuItem[] = [...items];
+ let overflowItems;
-const Breadcrumb = ({ document, children, onlyText }: Props) => {
- const { collections } = useStores();
- const { t } = useTranslation();
-
- if (!collections.isLoaded) {
- return;
+ // chop middle breadcrumbs and present a "..." menu instead
+ if (totalItems > max) {
+ const halfMax = Math.floor(max / 2);
+ overflowItems = topLevelItems.splice(halfMax, totalItems - max);
+ topLevelItems.splice(halfMax, 0, {
+ title: ,
+ });
}
- let collection = collections.get(document.collectionId);
- if (!collection) {
- collection = {
- id: document.collectionId,
- name: t("Deleted Collection"),
- color: "currentColor",
- };
- }
-
- const path = collection.pathToDocument
- ? collection.pathToDocument(document.id).slice(0, -1)
- : [];
-
- if (onlyText === true) {
- return (
- <>
- {collection.name}
- {path.map((n) => (
-
-
- {n.title}
-
- ))}
- >
- );
- }
-
- const isNestedDocument = path.length > 1;
- const lastPath = path.length ? path[path.length - 1] : undefined;
- const menuPath = isNestedDocument ? path.slice(0, -1) : [];
-
return (
-
-
-
-
- {collection.name}
-
- {isNestedDocument && (
- <>
-
- >
- )}
- {lastPath && (
- <>
- {" "}
-
- {lastPath.title}
-
- >
- )}
+ {topLevelItems.map((item, index) => (
+
+ {item.icon}
+ {item.to ? (
+ -
+ {item.title}
+
+ ) : (
+ item.title
+ )}
+ {index !== topLevelItems.length - 1 || !!children ? : null}
+
+ ))}
{children}
);
-};
+}
-export const Slash = styled(GoToIcon)`
+const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
-const SmallSlash = styled(GoToIcon)`
- width: 12px;
- height: 12px;
- vertical-align: middle;
- flex-shrink: 0;
-
- fill: ${(props) => props.theme.slate};
- opacity: 0.5;
-`;
-
-const Crumb = styled(Link)`
+const Item = styled(Link)`
+ display: flex;
+ flex-shrink: 1;
+ min-width: 0;
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
+ font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
+ margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
+
+ svg {
+ flex-shrink: 0;
+ }
&:hover {
text-decoration: underline;
}
`;
-const CollectionName = styled(Link)`
- display: flex;
- flex-shrink: 1;
- color: ${(props) => props.theme.text};
- font-size: 15px;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- min-width: 0;
-
- svg {
- flex-shrink: 0;
- }
-`;
-
-const CategoryName = styled(CollectionName)`
- flex-shrink: 0;
-`;
-
-export default observer(Breadcrumb);
+export default Breadcrumb;
diff --git a/app/components/DocumentBreadcrumb.js b/app/components/DocumentBreadcrumb.js
new file mode 100644
index 00000000..5f3fdebb
--- /dev/null
+++ b/app/components/DocumentBreadcrumb.js
@@ -0,0 +1,137 @@
+// @flow
+import { observer } from "mobx-react";
+import {
+ ArchiveIcon,
+ EditIcon,
+ GoToIcon,
+ ShapesIcon,
+ TrashIcon,
+} from "outline-icons";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+import Document from "models/Document";
+import Breadcrumb from "components/Breadcrumb";
+import CollectionIcon from "components/CollectionIcon";
+import useStores from "hooks/useStores";
+import { collectionUrl } from "utils/routeHelpers";
+
+type Props = {|
+ document: Document,
+ children?: React.Node,
+ onlyText: boolean,
+|};
+
+function useCategory(document) {
+ const { t } = useTranslation();
+
+ if (document.isDeleted) {
+ return {
+ icon: ,
+ title: t("Trash"),
+ to: "/trash",
+ };
+ }
+ if (document.isArchived) {
+ return {
+ icon: ,
+ title: t("Archive"),
+ to: "/archive",
+ };
+ }
+ if (document.isDraft) {
+ return {
+ icon: ,
+ title: t("Drafts"),
+ to: "/drafts",
+ };
+ }
+ if (document.isTemplate) {
+ return {
+ icon: ,
+ title: t("Templates"),
+ to: "/templates",
+ };
+ }
+ return null;
+}
+
+const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
+ const { collections } = useStores();
+ const { t } = useTranslation();
+ const category = useCategory(document);
+
+ let collection = collections.get(document.collectionId);
+ if (!collection) {
+ collection = {
+ id: document.collectionId,
+ name: t("Deleted Collection"),
+ color: "currentColor",
+ };
+ }
+
+ const path = React.useMemo(
+ () =>
+ collection && collection.pathToDocument
+ ? collection.pathToDocument(document.id).slice(0, -1)
+ : [],
+ [collection, document.id]
+ );
+
+ const items = React.useMemo(() => {
+ let output = [];
+
+ if (category) {
+ output.push(category);
+ }
+
+ if (collection) {
+ output.push({
+ icon: ,
+ title: collection.name,
+ to: collectionUrl(collection.id),
+ });
+ }
+
+ path.forEach((p) => {
+ output.push({
+ title: p.title,
+ to: p.url,
+ });
+ });
+
+ return output;
+ }, [path, category, collection]);
+
+ if (!collections.isLoaded) {
+ return;
+ }
+
+ if (onlyText === true) {
+ return (
+ <>
+ {collection.name}
+ {path.map((n) => (
+
+
+ {n.title}
+
+ ))}
+ >
+ );
+ }
+
+ return ;
+};
+
+const SmallSlash = styled(GoToIcon)`
+ width: 12px;
+ height: 12px;
+ vertical-align: middle;
+ flex-shrink: 0;
+
+ fill: ${(props) => props.theme.slate};
+ opacity: 0.5;
+`;
+
+export default observer(DocumentBreadcrumb);
diff --git a/app/components/DocumentMeta.js b/app/components/DocumentMeta.js
index b026b643..1ac34582 100644
--- a/app/components/DocumentMeta.js
+++ b/app/components/DocumentMeta.js
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
-import Breadcrumb from "components/Breadcrumb";
+import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";
@@ -142,7 +142,7 @@ function DocumentMeta({
{t("in")}
-
+
)}
diff --git a/app/components/Editor.js b/app/components/Editor.js
index 7eb7aef8..07542ba3 100644
--- a/app/components/Editor.js
+++ b/app/components/Editor.js
@@ -27,6 +27,7 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
+ shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
@@ -55,7 +56,7 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
- const { id, ui, history } = props;
+ const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const isPrinting = useMediaQuery("print");
@@ -89,12 +90,16 @@ function Editor(props: PropsWithRef) {
}
}
+ if (shareId) {
+ navigateTo = `/share/${shareId}${navigateTo}`;
+ }
+
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
- [history]
+ [history, shareId]
);
const onShowToast = React.useCallback(
diff --git a/app/components/SocketProvider.js b/app/components/SocketProvider.js
index 0e1c654b..c8c1808f 100644
--- a/app/components/SocketProvider.js
+++ b/app/components/SocketProvider.js
@@ -144,9 +144,10 @@ class SocketProvider extends React.Component {
// otherwise, grab the latest version of the document
try {
- document = await documents.fetch(documentId, {
+ const response = await documents.fetch(documentId, {
force: true,
});
+ document = response.document;
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
diff --git a/app/hooks/useImportDocument.js b/app/hooks/useImportDocument.js
index 6f1fd91e..de68824b 100644
--- a/app/hooks/useImportDocument.js
+++ b/app/hooks/useImportDocument.js
@@ -36,7 +36,7 @@ export default function useImportDocument(
const redirect = files.length === 1;
if (documentId && !collectionId) {
- const document = await documents.fetch(documentId);
+ const { document } = await documents.fetch(documentId);
invariant(document, "Document not available");
cId = document.collectionId;
}
diff --git a/app/menus/BreadcrumbMenu.js b/app/menus/BreadcrumbMenu.js
index 097acdc0..3ccb1b15 100644
--- a/app/menus/BreadcrumbMenu.js
+++ b/app/menus/BreadcrumbMenu.js
@@ -6,11 +6,17 @@ import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
+type MenuItem = {|
+ icon?: React.Node,
+ title: React.Node,
+ to?: string,
+|};
+
type Props = {
- path: Array,
+ items: MenuItem[],
};
-export default function BreadcrumbMenu({ path }: Props) {
+export default function BreadcrumbMenu({ items }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
@@ -21,13 +27,7 @@ export default function BreadcrumbMenu({ path }: Props) {
<>
- ({
- title: item.title,
- to: item.url,
- }))}
- />
+
>
);
diff --git a/app/models/Share.js b/app/models/Share.js
index c290271a..b1902fc7 100644
--- a/app/models/Share.js
+++ b/app/models/Share.js
@@ -10,6 +10,7 @@ class Share extends BaseModel {
documentTitle: string;
documentUrl: string;
lastAccessedAt: ?string;
+ includeChildDocuments: boolean;
createdBy: User;
createdAt: string;
updatedAt: string;
diff --git a/app/routes/index.js b/app/routes/index.js
index 06780afc..1262a68f 100644
--- a/app/routes/index.js
+++ b/app/routes/index.js
@@ -4,6 +4,7 @@ import { Switch } from "react-router-dom";
import DelayedMount from "components/DelayedMount";
import FullscreenLoading from "components/FullscreenLoading";
import Route from "components/ProfiledRoute";
+import { matchDocumentSlug as slug } from "utils/routeHelpers";
const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
@@ -25,6 +26,11 @@ export default function Routes() {
+
diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js
index b65d1ac4..b9ccd401 100644
--- a/app/scenes/Document/components/DataLoader.js
+++ b/app/scenes/Document/components/DataLoader.js
@@ -22,11 +22,10 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
-import { type LocationWithState } from "types";
+import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
-
type Props = {|
match: Match,
location: LocationWithState,
@@ -41,6 +40,7 @@ type Props = {|
@observer
class DataLoader extends React.Component {
+ @observable sharedTree: ?NavigationNode;
@observable document: ?Document;
@observable revision: ?Revision;
@observable error: ?Error;
@@ -89,7 +89,7 @@ class DataLoader extends React.Component {
// search for exact internal document
const slug = parseDocumentSlug(term);
try {
- const document = await this.props.documents.fetch(slug);
+ const { document } = await this.props.documents.fetch(slug);
const time = distanceInWordsToNow(document.updatedAt, {
addSuffix: true,
});
@@ -159,10 +159,13 @@ class DataLoader extends React.Component {
}
try {
- this.document = await this.props.documents.fetch(documentSlug, {
+ const response = await this.props.documents.fetch(documentSlug, {
shareId,
});
+ this.document = response.document;
+ this.sharedTree = response.sharedTree;
+
if (revisionId && revisionId !== "latest") {
await this.loadRevision();
} else {
@@ -249,6 +252,7 @@ class DataLoader extends React.Component {
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
+ sharedTree={this.sharedTree}
/>
);
diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js
index 2768b42f..c5b2c0bf 100644
--- a/app/scenes/Document/components/Document.js
+++ b/app/scenes/Document/components/Document.js
@@ -29,8 +29,9 @@ import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
+import PublicReferences from "./PublicReferences";
import References from "./References";
-import { type LocationWithState, type Theme } from "types";
+import { type LocationWithState, type NavigationNode, type Theme } from "types";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
import { meta } from "utils/keyboard";
@@ -57,6 +58,7 @@ type Props = {
match: Match,
history: RouterHistory,
location: LocationWithState,
+ sharedTree: ?NavigationNode,
abilities: Object,
document: Document,
revision: Revision,
@@ -311,7 +313,8 @@ class DocumentScene extends React.Component {
match,
} = this.props;
const team = auth.team;
- const isShare = !!match.params.shareId;
+ const { shareId } = match.params;
+ const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
@@ -367,7 +370,7 @@ class DocumentScene extends React.Component {
)}
{
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
+ sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSave={this.onSave}
/>
@@ -420,7 +424,7 @@ class DocumentScene extends React.Component {
{
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
>
+ {shareId && (
+
+
+
+ )}
{!isShare && !revision && (
<>
diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js
index b640ed79..9d104659 100644
--- a/app/scenes/Document/components/Editor.js
+++ b/app/scenes/Document/components/Editor.js
@@ -24,7 +24,7 @@ type Props = {|
title: string,
document: Document,
isDraft: boolean,
- isShare: boolean,
+ shareId: ?string,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
@@ -97,7 +97,7 @@ class DocumentEditor extends React.Component {
title,
onChangeTitle,
isDraft,
- isShare,
+ shareId,
readOnly,
innerRef,
children,
@@ -118,7 +118,7 @@ class DocumentEditor extends React.Component {
$isStarred={document.isStarred}
>
{normalizedTitle}{" "}
- {!isShare && }
+ {!shareId && }
) : (
{
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
+ shareId={shareId}
grow
{...rest}
/>
{!readOnly && }
- {this.activeLinkEvent && !isShare && readOnly && (
+ {this.activeLinkEvent && !shareId && readOnly && (
);
- if (isShare) {
+ if (shareId) {
return (
+ {toc}
+
+ }
actions={canEdit ? editAction : }
/>
);
@@ -129,14 +141,9 @@ function DocumentHeader({
<>
- {!isEditing && (
- <>
-
- {toc}
- >
- )}
-
+
+ {!isEditing && toc}
+
}
title={
<>
diff --git a/app/scenes/Document/components/PublicBreadcrumb.js b/app/scenes/Document/components/PublicBreadcrumb.js
new file mode 100644
index 00000000..fd9435e2
--- /dev/null
+++ b/app/scenes/Document/components/PublicBreadcrumb.js
@@ -0,0 +1,54 @@
+// @flow
+import * as React from "react";
+import Breadcrumb from "components/Breadcrumb";
+import type { NavigationNode } from "types";
+
+type Props = {|
+ documentId: string,
+ shareId: string,
+ sharedTree: ?NavigationNode,
+ children?: React.Node,
+|};
+
+function pathToDocument(sharedTree, documentId) {
+ let path = [];
+ const traveler = (nodes, previousPath) => {
+ nodes.forEach((childNode) => {
+ const newPath = [...previousPath, childNode];
+ if (childNode.id === documentId) {
+ path = newPath;
+ return;
+ }
+ return traveler(childNode.children, newPath);
+ });
+ };
+
+ if (sharedTree) {
+ traveler([sharedTree], []);
+ }
+ return path;
+}
+
+const PublicBreadcrumb = ({
+ documentId,
+ shareId,
+ sharedTree,
+ children,
+}: Props) => {
+ const items = React.useMemo(
+ () =>
+ pathToDocument(sharedTree, documentId)
+ .slice(0, -1)
+ .map((item) => {
+ return {
+ ...item,
+ to: `/share/${shareId}${item.url}`,
+ };
+ }),
+ [sharedTree, shareId, documentId]
+ );
+
+ return ;
+};
+
+export default PublicBreadcrumb;
diff --git a/app/scenes/Document/components/PublicReferences.js b/app/scenes/Document/components/PublicReferences.js
new file mode 100644
index 00000000..466f46d1
--- /dev/null
+++ b/app/scenes/Document/components/PublicReferences.js
@@ -0,0 +1,57 @@
+// @flow
+import { observer } from "mobx-react";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import Subheading from "components/Subheading";
+import ReferenceListItem from "./ReferenceListItem";
+import { type NavigationNode } from "types";
+
+type Props = {|
+ shareId: string,
+ documentId: string,
+ sharedTree: NavigationNode,
+|};
+
+function PublicReferences(props: Props) {
+ const { t } = useTranslation();
+ const { shareId, documentId, sharedTree } = props;
+
+ // The sharedTree is the entire document tree starting at the shared document
+ // we must filter down the tree to only the part with the document we're
+ // currently viewing
+ const children = React.useMemo(() => {
+ let result;
+
+ function findChildren(node) {
+ if (!node) return;
+ if (node.id === documentId) {
+ result = node.children;
+ } else {
+ node.children.forEach((node) => {
+ if (result) {
+ return;
+ }
+ findChildren(node);
+ });
+ }
+ return result;
+ }
+
+ return findChildren(sharedTree) || [];
+ }, [documentId, sharedTree]);
+
+ if (!children.length) {
+ return null;
+ }
+
+ return (
+ <>
+ {t("Nested documents")}
+ {children.map((node) => (
+
+ ))}
+ >
+ );
+}
+
+export default observer(PublicReferences);
diff --git a/app/scenes/Document/components/ReferenceListItem.js b/app/scenes/Document/components/ReferenceListItem.js
index 29124a30..c7c91a6a 100644
--- a/app/scenes/Document/components/ReferenceListItem.js
+++ b/app/scenes/Document/components/ReferenceListItem.js
@@ -1,5 +1,6 @@
// @flow
import { observer } from "mobx-react";
+import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import DocumentMeta from "components/DocumentMeta";
import type { NavigationNode } from "types";
type Props = {|
+ shareId?: string,
document: Document | NavigationNode,
anchor?: string,
showCollection?: boolean,
@@ -31,6 +33,8 @@ const DocumentLink = styled(Link)`
`;
const Title = styled.h3`
+ display: flex;
+ align-items: center;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
@@ -43,27 +47,52 @@ const Title = styled.h3`
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
-@observer
-class ReferenceListItem extends React.Component {
- render() {
- const { document, showCollection, anchor, ...rest } = this.props;
+const StyledDocumentIcon = styled(DocumentIcon)`
+ margin-left: -4px;
+ color: ${(props) => props.theme.textSecondary};
+`;
- return (
-
- {document.title}
- {document.updatedBy && (
-
- )}
-
- );
- }
+const Emoji = styled.span`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: -4px;
+ font-size: 16px;
+ width: 24px;
+ height: 24px;
+`;
+
+function ReferenceListItem({
+ document,
+ showCollection,
+ anchor,
+ shareId,
+ ...rest
+}: Props) {
+ return (
+
+
+ {document.emoji ? (
+ {document.emoji}
+ ) : (
+
+ )}{" "}
+ {document.emoji
+ ? document.title.replace(new RegExp(`^${document.emoji}`), "")
+ : document.title}
+
+ {document.updatedBy && (
+
+ )}
+
+ );
}
-export default ReferenceListItem;
+export default observer(ReferenceListItem);
diff --git a/app/scenes/Document/components/ShareButton.js b/app/scenes/Document/components/ShareButton.js
index ab691a60..2862d385 100644
--- a/app/scenes/Document/components/ShareButton.js
+++ b/app/scenes/Document/components/ShareButton.js
@@ -19,7 +19,9 @@ function ShareButton({ document }: Props) {
const { t } = useTranslation();
const { shares } = useStores();
const share = shares.getByDocumentId(document.id);
- const isPubliclyShared = share && share.published;
+ const sharedParent = shares.getByDocumentParents(document.id);
+ const isPubliclyShared =
+ (share && share.published) || (sharedParent && sharedParent.published);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
@@ -57,6 +59,7 @@ function ShareButton({ document }: Props) {
diff --git a/app/scenes/Document/components/SharePopover.js b/app/scenes/Document/components/SharePopover.js
index 4ea2b9a6..18698d87 100644
--- a/app/scenes/Document/components/SharePopover.js
+++ b/app/scenes/Document/components/SharePopover.js
@@ -4,7 +4,7 @@ import invariant from "invariant";
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react";
-import { useTranslation } from "react-i18next";
+import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Share from "models/Share";
@@ -13,23 +13,25 @@ import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
+import Notice from "components/Notice";
import Switch from "components/Switch";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
share: Share,
+ sharedParent: ?Share,
onSubmit: () => void,
|};
-function DocumentShare({ document, share, onSubmit }: Props) {
+function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation();
const { policies, shares, ui } = useStores();
const [isCopied, setIsCopied] = React.useState(false);
- const [isSaving, setIsSaving] = React.useState(false);
const timeout = React.useRef();
const can = policies.abilities(share ? share.id : "");
const canPublish = can.update && !document.isTemplate;
+ const isPubliclyShared = (share && share.published) || sharedParent;
React.useEffect(() => {
document.share();
@@ -41,14 +43,26 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
- setIsSaving(true);
-
try {
await share.save({ published: event.currentTarget.checked });
} catch (err) {
ui.showToast(err.message, { type: "error" });
- } finally {
- setIsSaving(false);
+ }
+ },
+ [document.id, shares, ui]
+ );
+
+ const handleChildDocumentsChange = React.useCallback(
+ async (event) => {
+ const share = shares.getByDocumentId(document.id);
+ invariant(share, "Share must exist");
+
+ try {
+ await share.save({
+ includeChildDocuments: event.currentTarget.checked,
+ });
+ } catch (err) {
+ ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, ui]
@@ -68,7 +82,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
return (
<>
- {share && share.published ? (
+ {isPubliclyShared ? (
) : (
@@ -76,20 +90,30 @@ function DocumentShare({ document, share, onSubmit }: Props) {
{t("Share this document")}
+ {sharedParent && (
+
+ }}
+ />
+
+ )}
+
{canPublish && (
-
+
-
-
+
+
{share.published
? t("Anyone with the link can view this document")
- : t("Only team members with access can view")}
+ : t("Only team members with permission can view")}
{share.lastAccessedAt && (
<>
.{" "}
@@ -100,9 +124,27 @@ function DocumentShare({ document, share, onSubmit }: Props) {
})}
>
)}
-
-
-
+
+
+
+ )}
+ {share && share.published && (
+
+
+
+
+ {share.includeChildDocuments
+ ? t("Nested documents are publicly available")
+ : t("Nested documents are not shared")}
+
+
+
)}
{
fetch = async (
id: string,
options: FetchOptions = {}
- ): Promise => {
+ ): Promise<{ document: ?Document, sharedTree?: NavigationNode }> => {
if (!options.prefetch) this.isFetching = true;
try {
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
if (doc && policy && !options.force) {
- return doc;
+ return { document: doc };
}
const res = await client.post("/documents.info", {
id,
shareId: options.shareId,
+ apiVersion: 2,
});
invariant(res && res.data, "Document not available");
this.addPolicies(res.policies);
- this.add(res.data);
+ this.add(res.data.document);
- runInAction("DocumentsStore#fetch", () => {
- this.isLoaded = true;
- });
-
- return this.data.get(res.data.id);
+ return {
+ document: this.data.get(res.data.document.id),
+ sharedTree: res.data.sharedTree,
+ };
} finally {
this.isFetching = false;
}
diff --git a/app/stores/SharesStore.js b/app/stores/SharesStore.js
index 0f49adc9..39c91e7d 100644
--- a/app/stores/SharesStore.js
+++ b/app/stores/SharesStore.js
@@ -46,19 +46,42 @@ export default class SharesStore extends BaseStore {
this.isFetching = true;
try {
- const res = await client.post(`/${this.modelName}s.info`, { documentId });
+ const res = await client.post(`/${this.modelName}s.info`, {
+ documentId,
+ apiVersion: 2,
+ });
if (isUndefined(res)) return;
invariant(res && res.data, "Data should be available");
this.addPolicies(res.policies);
- return this.add(res.data);
+ return res.data.shares.map(this.add);
} finally {
this.isFetching = false;
}
}
- getByDocumentId = (documentId): ?Share => {
+ getByDocumentParents = (documentId: string): ?Share => {
+ const document = this.rootStore.documents.get(documentId);
+ if (!document) return;
+
+ const collection = this.rootStore.collections.get(document.collectionId);
+ if (!collection) return;
+
+ const parentIds = collection
+ .pathToDocument(documentId)
+ .slice(0, -1)
+ .map((p) => p.id);
+
+ for (const parentId of parentIds) {
+ const share = this.getByDocumentId(parentId);
+ if (share && share.includeChildDocuments && share.published) {
+ return share;
+ }
+ }
+ };
+
+ getByDocumentId = (documentId: string): ?Share => {
return find(this.orderedData, (share) => share.documentId === documentId);
};
}
diff --git a/server/api/documents.js b/server/api/documents.js
index 232ddd56..8083af28 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -471,11 +471,17 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => {
};
});
-async function loadDocument({ id, shareId, user }) {
+async function loadDocument({
+ id,
+ shareId,
+ user,
+}): Promise<{ document: Document, share?: Share, collection: Collection }> {
let document;
+ let collection;
+ let share;
if (shareId) {
- const share = await Share.findOne({
+ share = await Share.findOne({
where: {
revokedAt: { [Op.eq]: null },
id: shareId,
@@ -497,7 +503,20 @@ async function loadDocument({ id, shareId, user }) {
throw new InvalidRequestError("Document could not be found for shareId");
}
- if (user) {
+ // It is possible to pass both an id and a shareId to the documents.info
+ // endpoint. In this case we'll load the document based on the `id` and check
+ // if the provided share token allows access. This is used by the frontend
+ // to navigate nested documents from a single share link.
+ if (id) {
+ document = await Document.findByPk(id, {
+ userId: user ? user.id : undefined,
+ paranoid: false,
+ });
+
+ // otherwise, if the user has an authenticated session make sure to load
+ // with their details so that we can return the correct policies, they may
+ // be able to edit the shared document
+ } else if (user) {
document = await Document.findByPk(share.documentId, {
userId: user.id,
paranoid: false,
@@ -506,15 +525,31 @@ async function loadDocument({ id, shareId, user }) {
document = share.document;
}
+ // "published" === on the public internet. So if the share isn't published
+ // then we must have permission to read the document
if (!share.published) {
authorize(user, "read", document);
}
- const collection = await Collection.findByPk(document.collectionId);
+ // It is possible to disable sharing at the collection so we must check
+ collection = await Collection.findByPk(document.collectionId);
if (!collection.sharing) {
throw new AuthorizationError();
}
+ // If we're attempting to load a document that isn't the document originally
+ // shared then includeChildDocuments must be enabled and the document must
+ // still be nested within the shared document
+ if (share.document.id !== document.id) {
+ if (
+ !share.includeChildDocuments ||
+ !collection.isChildDocument(share.document.id, document.id)
+ ) {
+ throw new AuthorizationError();
+ }
+ }
+
+ // It is possible to disable sharing at the team level so we must check
const team = await Team.findByPk(document.teamId);
if (!team.sharing) {
throw new AuthorizationError();
@@ -535,21 +570,41 @@ async function loadDocument({ id, shareId, user }) {
} else {
authorize(user, "read", document);
}
+
+ collection = document.collection;
}
- return document;
+ return { document, share, collection };
}
router.post("documents.info", auth({ required: false }), async (ctx) => {
- const { id, shareId } = ctx.body;
+ const { id, shareId, apiVersion } = ctx.body;
ctx.assertPresent(id || shareId, "id or shareId is required");
- const user = ctx.state.user;
- const document = await loadDocument({ id, shareId, user });
+ const { user } = ctx.state;
+ const { document, share, collection } = await loadDocument({
+ id,
+ shareId,
+ user,
+ });
const isPublic = cannot(user, "read", document);
+ const serializedDocument = await presentDocument(document, { isPublic });
+
+ // Passing apiVersion=2 has a single effect, to change the response payload to
+ // include document and sharedTree keys.
+ const data =
+ apiVersion === 2
+ ? {
+ document: serializedDocument,
+ sharedTree:
+ share && share.includeChildDocuments
+ ? collection.getDocumentTree(share.documentId)
+ : undefined,
+ }
+ : serializedDocument;
ctx.body = {
- data: await presentDocument(document, { isPublic }),
+ data,
policies: isPublic ? undefined : presentPolicies(user, [document]),
};
});
@@ -559,7 +614,7 @@ router.post("documents.export", auth({ required: false }), async (ctx) => {
ctx.assertPresent(id || shareId, "id or shareId is required");
const user = ctx.state.user;
- const document = await loadDocument({ id, shareId, user });
+ const { document } = await loadDocument({ id, shareId, user });
ctx.body = {
data: document.toMarkdown(),
diff --git a/server/api/documents.test.js b/server/api/documents.test.js
index 026856d9..352d218f 100644
--- a/server/api/documents.test.js
+++ b/server/api/documents.test.js
@@ -98,6 +98,109 @@ describe("#documents.info", () => {
expect(share.lastAccessedAt).toBeTruthy();
});
+ describe("apiVersion=2", () => {
+ it("should return sharedTree from shareId", async () => {
+ const { document, collection, user } = await seed();
+ const childDocument = await buildDocument({
+ teamId: document.teamId,
+ parentDocumentId: document.id,
+ collectionId: collection.id,
+ });
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: true,
+ });
+
+ await collection.addDocumentToStructure(childDocument, 0);
+
+ const res = await server.post("/api/documents.info", {
+ body: { shareId: share.id, id: childDocument.id, apiVersion: 2 },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.document.id).toEqual(childDocument.id);
+ expect(body.data.document.createdBy).toEqual(undefined);
+ expect(body.data.document.updatedBy).toEqual(undefined);
+ expect(body.data.sharedTree).toEqual(collection.documentStructure[0]);
+
+ await share.reload();
+ expect(share.lastAccessedAt).toBeTruthy();
+ });
+
+ it("should return sharedTree from shareId with id of nested document", async () => {
+ const { document, user } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: true,
+ });
+
+ const res = await server.post("/api/documents.info", {
+ body: { shareId: share.id, apiVersion: 2 },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.document.id).toEqual(document.id);
+ expect(body.data.document.createdBy).toEqual(undefined);
+ expect(body.data.document.updatedBy).toEqual(undefined);
+ expect(body.data.sharedTree).toEqual(document.toJSON());
+
+ await share.reload();
+ expect(share.lastAccessedAt).toBeTruthy();
+ });
+
+ it("should not return sharedTree if child documents not shared", async () => {
+ const { document, user } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: false,
+ });
+
+ const res = await server.post("/api/documents.info", {
+ body: { shareId: share.id, apiVersion: 2 },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.document.id).toEqual(document.id);
+ expect(body.data.document.createdBy).toEqual(undefined);
+ expect(body.data.document.updatedBy).toEqual(undefined);
+ expect(body.data.sharedTree).toEqual(undefined);
+
+ await share.reload();
+ expect(share.lastAccessedAt).toBeTruthy();
+ });
+
+ it("should not return details for nested documents", async () => {
+ const { document, collection, user } = await seed();
+ const childDocument = await buildDocument({
+ teamId: document.teamId,
+ parentDocumentId: document.id,
+ collectionId: collection.id,
+ });
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: false,
+ });
+
+ await collection.addDocumentToStructure(childDocument, 0);
+
+ const res = await server.post("/api/documents.info", {
+ body: { shareId: share.id, id: childDocument.id, apiVersion: 2 },
+ });
+ expect(res.status).toEqual(403);
+ });
+ });
+
it("should not return document from shareId if sharing is disabled for team", async () => {
const { document, team, user } = await seed();
const share = await buildShare({
diff --git a/server/api/shares.js b/server/api/shares.js
index 11ac62b2..374d9355 100644
--- a/server/api/shares.js
+++ b/server/api/shares.js
@@ -13,11 +13,12 @@ const { authorize } = policy;
const router = new Router();
router.post("shares.info", auth(), async (ctx) => {
- const { id, documentId } = ctx.body;
+ const { id, documentId, apiVersion } = ctx.body;
ctx.assertUuid(id || documentId, "id or documentId is required");
const user = ctx.state.user;
- const share = await Share.findOne({
+ let shares = [];
+ let share = await Share.findOne({
where: id
? {
id,
@@ -29,15 +30,62 @@ router.post("shares.info", auth(), async (ctx) => {
revokedAt: { [Op.eq]: null },
},
});
- if (!share || !share.document) {
+
+ // Deprecated API response returns just the share for the current documentId
+ if (apiVersion !== 2) {
+ if (!share || !share.document) {
+ return (ctx.response.status = 204);
+ }
+
+ authorize(user, "read", share);
+
+ ctx.body = {
+ data: presentShare(share, user.isAdmin),
+ policies: presentPolicies(user, [share]),
+ };
+ return;
+ }
+
+ // API version 2 returns the response for the current documentId and any
+ // parent documents that are publicly shared and accessible to the user
+ if (share && share.document) {
+ authorize(user, "read", share);
+ shares.push(share);
+ }
+
+ if (documentId) {
+ const document = await Document.scope("withCollection").findByPk(
+ documentId
+ );
+ const parentIds = document?.collection?.getDocumentParents(documentId);
+
+ const parentShare = parentIds
+ ? await Share.findOne({
+ where: {
+ documentId: parentIds,
+ teamId: user.teamId,
+ revokedAt: { [Op.eq]: null },
+ includeChildDocuments: true,
+ published: true,
+ },
+ })
+ : undefined;
+
+ if (parentShare && parentShare.document) {
+ authorize(user, "read", parentShare);
+ shares.push(parentShare);
+ }
+ }
+
+ if (!shares.length) {
return (ctx.response.status = 204);
}
- authorize(user, "read", share);
-
ctx.body = {
- data: presentShare(share, user.isAdmin),
- policies: presentPolicies(user, [share]),
+ data: {
+ shares: shares.map((share) => presentShare(share, user.isAdmin)),
+ },
+ policies: presentPolicies(user, shares),
};
});
@@ -95,15 +143,28 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
});
router.post("shares.update", auth(), async (ctx) => {
- const { id, published } = ctx.body;
+ const { id, includeChildDocuments, published } = ctx.body;
ctx.assertUuid(id, "id is required");
- ctx.assertPresent(published, "published is required");
- const user = ctx.state.user;
+ const { user } = ctx.state;
const share = await Share.findByPk(id);
authorize(user, "update", share);
- share.published = published;
+ if (published !== undefined) {
+ share.published = published;
+
+ // Reset nested document sharing when unpublishing a share link. So that
+ // If it's ever re-published this doesn't immediately share nested docs
+ // without forewarning the user
+ if (!published) {
+ share.includeChildDocuments = false;
+ }
+ }
+
+ if (includeChildDocuments !== undefined) {
+ share.includeChildDocuments = includeChildDocuments;
+ }
+
await share.save();
await Event.create({
diff --git a/server/api/shares.test.js b/server/api/shares.test.js
index 20402490..baff2ffc 100644
--- a/server/api/shares.test.js
+++ b/server/api/shares.test.js
@@ -2,7 +2,7 @@
import TestServer from "fetch-test-server";
import app from "../app";
import { CollectionUser } from "../models";
-import { buildUser, buildShare } from "../test/factories";
+import { buildUser, buildDocument, buildShare } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -260,7 +260,7 @@ describe("#shares.info", () => {
expect(body.data.createdBy.id).toBe(user.id);
});
- it("should allow reading share creaded by deleted user", async () => {
+ it("should allow reading share created by deleted user", async () => {
const { user, document } = await seed();
const author = await buildUser({ teamId: user.teamId });
const share = await buildShare({
@@ -347,6 +347,135 @@ describe("#shares.info", () => {
expect(res.status).toEqual(204);
});
+ describe("apiVersion=2", () => {
+ it("should allow reading share by documentId", async () => {
+ const { user, document } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: user.teamId,
+ userId: user.id,
+ });
+
+ const res = await server.post("/api/shares.info", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ apiVersion: 2,
+ },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.shares.length).toBe(1);
+ expect(body.data.shares[0].id).toBe(share.id);
+ expect(body.data.shares[0].published).toBe(true);
+ });
+
+ it("should return share for parent document with includeChildDocuments=true", async () => {
+ const { user, document, collection } = await seed();
+ const childDocument = await buildDocument({
+ teamId: document.teamId,
+ parentDocumentId: document.id,
+ collectionId: collection.id,
+ });
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: true,
+ });
+
+ await collection.addDocumentToStructure(childDocument, 0);
+
+ const res = await server.post("/api/shares.info", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: childDocument.id,
+ apiVersion: 2,
+ },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.shares.length).toBe(1);
+ expect(body.data.shares[0].id).toBe(share.id);
+ expect(body.data.shares[0].documentId).toBe(document.id);
+ expect(body.data.shares[0].published).toBe(true);
+ expect(body.data.shares[0].includeChildDocuments).toBe(true);
+ });
+
+ it("should not return share for parent document with includeChildDocuments=false", async () => {
+ const { user, document, collection } = await seed();
+ const childDocument = await buildDocument({
+ teamId: document.teamId,
+ parentDocumentId: document.id,
+ collectionId: collection.id,
+ });
+ await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: false,
+ });
+
+ await collection.addDocumentToStructure(childDocument, 0);
+
+ const res = await server.post("/api/shares.info", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: childDocument.id,
+ apiVersion: 2,
+ },
+ });
+
+ expect(res.status).toEqual(204);
+ });
+
+ it("should return shares for parent document and current document", async () => {
+ const { user, document, collection } = await seed();
+ const childDocument = await buildDocument({
+ teamId: document.teamId,
+ parentDocumentId: document.id,
+ collectionId: collection.id,
+ });
+ const share = await buildShare({
+ documentId: childDocument.id,
+ teamId: user.teamId,
+ userId: user.id,
+ includeChildDocuments: false,
+ });
+ const share2 = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ userId: user.id,
+ includeChildDocuments: true,
+ });
+
+ await collection.addDocumentToStructure(childDocument, 0);
+
+ const res = await server.post("/api/shares.info", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: childDocument.id,
+ apiVersion: 2,
+ },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.shares.length).toBe(2);
+ expect(body.data.shares[0].id).toBe(share.id);
+ expect(body.data.shares[0].includeChildDocuments).toBe(false);
+ expect(body.data.shares[0].documentId).toBe(childDocument.id);
+ expect(body.data.shares[0].published).toBe(true);
+
+ expect(body.data.shares[1].id).toBe(share2.id);
+ expect(body.data.shares[1].documentId).toBe(document.id);
+ expect(body.data.shares[1].published).toBe(true);
+ expect(body.data.shares[1].includeChildDocuments).toBe(true);
+ });
+ });
+
it("should require authentication", async () => {
const { user, document } = await seed();
const share = await buildShare({
diff --git a/server/migrations/20210418053152-share-last-viewed.js b/server/migrations/20210418053152-share-last-viewed.js
index c84076bc..071011ee 100644
--- a/server/migrations/20210418053152-share-last-viewed.js
+++ b/server/migrations/20210418053152-share-last-viewed.js
@@ -2,7 +2,7 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
- await queryInterface.addColumn("shares", "lastAccessedAt", {
+ await queryInterface.addColumn("shares", "lastAccessedAt", {
type: Sequelize.DATE,
allowNull: true,
});
diff --git a/server/migrations/20210426055334-nested-document-sharing.js b/server/migrations/20210426055334-nested-document-sharing.js
new file mode 100644
index 00000000..6bf39b30
--- /dev/null
+++ b/server/migrations/20210426055334-nested-document-sharing.js
@@ -0,0 +1,15 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn("shares", "includeChildDocuments", {
+ type: Sequelize.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ });
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn("shares", "includeChildDocuments");
+ }
+};
diff --git a/server/models/Collection.js b/server/models/Collection.js
index 50e7ea33..61f5d0c7 100644
--- a/server/models/Collection.js
+++ b/server/models/Collection.js
@@ -375,6 +375,77 @@ Collection.prototype.deleteDocument = async function (document) {
await document.deleteWithChildren();
};
+Collection.prototype.isChildDocument = function (
+ parentDocumentId,
+ documentId
+): boolean {
+ let result = false;
+
+ const loopChildren = (documents, input) => {
+ return documents.map((document) => {
+ let parents = [...input];
+ if (document.id === documentId) {
+ result = parents.includes(parentDocumentId);
+ } else {
+ parents.push(document.id);
+ loopChildren(document.children, parents);
+ }
+ return document;
+ });
+ };
+
+ loopChildren(this.documentStructure, []);
+
+ return result;
+};
+
+Collection.prototype.getDocumentTree = function (documentId: string) {
+ let result;
+
+ const loopChildren = (documents) => {
+ if (result) {
+ return;
+ }
+
+ documents.forEach((document) => {
+ if (result) {
+ return;
+ }
+ if (document.id === documentId) {
+ result = document;
+ } else {
+ loopChildren(document.children);
+ }
+ });
+ };
+
+ loopChildren(this.documentStructure);
+ return result;
+};
+
+Collection.prototype.getDocumentParents = function (
+ documentId: string
+): string[] | void {
+ let result;
+
+ const loopChildren = (documents, path = []) => {
+ if (result) {
+ return;
+ }
+
+ documents.forEach((document) => {
+ if (document.id === documentId) {
+ result = path;
+ } else {
+ loopChildren(document.children, [...path, document.id]);
+ }
+ });
+ };
+
+ loopChildren(this.documentStructure);
+ return result;
+};
+
Collection.prototype.removeDocumentInStructure = async function (
document,
options
diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js
index 93b7f3a2..8d65f247 100644
--- a/server/models/Collection.test.js
+++ b/server/models/Collection.test.js
@@ -6,6 +6,7 @@ import {
buildGroup,
buildCollection,
buildTeam,
+ buildDocument,
} from "../test/factories";
import { flushdb, seed } from "../test/support";
@@ -19,6 +20,134 @@ describe("#url", () => {
});
});
+describe("getDocumentParents", () => {
+ test("should return array of parent document ids", async () => {
+ const parent = await buildDocument();
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [
+ {
+ ...parent.toJSON(),
+ children: [document.toJSON()],
+ },
+ ],
+ });
+
+ const result = collection.getDocumentParents(document.id);
+
+ expect(result.length).toBe(1);
+ expect(result[0]).toBe(parent.id);
+ });
+
+ test("should return array of parent document ids", async () => {
+ const parent = await buildDocument();
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [
+ {
+ ...parent.toJSON(),
+ children: [document.toJSON()],
+ },
+ ],
+ });
+
+ const result = collection.getDocumentParents(parent.id);
+ expect(result.length).toBe(0);
+ });
+});
+
+describe("getDocumentTree", () => {
+ test("should return document tree", async () => {
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [document.toJSON()],
+ });
+
+ expect(collection.getDocumentTree(document.id)).toEqual(document.toJSON());
+ });
+
+ test("should return nested documents in tree", async () => {
+ const parent = await buildDocument();
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [
+ {
+ ...parent.toJSON(),
+ children: [document.toJSON()],
+ },
+ ],
+ });
+
+ expect(collection.getDocumentTree(parent.id)).toEqual({
+ ...parent.toJSON(),
+ children: [document.toJSON()],
+ });
+ expect(collection.getDocumentTree(document.id)).toEqual(document.toJSON());
+ });
+});
+
+describe("isChildDocument", () => {
+ test("should return false with unexpected data", async () => {
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [document.toJSON()],
+ });
+
+ expect(collection.isChildDocument(document.id, document.id)).toEqual(false);
+ expect(collection.isChildDocument(document.id, undefined)).toEqual(false);
+ expect(collection.isChildDocument(undefined, document.id)).toEqual(false);
+ });
+
+ test("should return false if sibling", async () => {
+ const one = await buildDocument();
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [one.toJSON(), document.toJSON()],
+ });
+
+ expect(collection.isChildDocument(one.id, document.id)).toEqual(false);
+ expect(collection.isChildDocument(document.id, one.id)).toEqual(false);
+ });
+
+ test("should return true if direct child of parent", async () => {
+ const parent = await buildDocument();
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [
+ {
+ ...parent.toJSON(),
+ children: [document.toJSON()],
+ },
+ ],
+ });
+
+ expect(collection.isChildDocument(parent.id, document.id)).toEqual(true);
+ expect(collection.isChildDocument(document.id, parent.id)).toEqual(false);
+ });
+
+ test("should return true if nested child of parent", async () => {
+ const parent = await buildDocument();
+ const nested = await buildDocument();
+ const document = await buildDocument();
+ const collection = await buildCollection({
+ documentStructure: [
+ {
+ ...parent.toJSON(),
+ children: [
+ {
+ ...nested.toJSON(),
+ children: [document.toJSON()],
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(collection.isChildDocument(parent.id, document.id)).toEqual(true);
+ expect(collection.isChildDocument(document.id, parent.id)).toEqual(false);
+ });
+});
+
describe("#addDocumentToStructure", () => {
test("should add as last element without index", async () => {
const { collection } = await seed();
diff --git a/server/models/Share.js b/server/models/Share.js
index 9adefde5..3b4f8247 100644
--- a/server/models/Share.js
+++ b/server/models/Share.js
@@ -10,6 +10,7 @@ const Share = sequelize.define(
primaryKey: true,
},
published: DataTypes.BOOLEAN,
+ includeChildDocuments: DataTypes.BOOLEAN,
revokedAt: DataTypes.DATE,
revokedById: DataTypes.UUID,
lastAccessedAt: DataTypes.DATE,
diff --git a/server/presenters/share.js b/server/presenters/share.js
index 4d2ae962..cf91a6e1 100644
--- a/server/presenters/share.js
+++ b/server/presenters/share.js
@@ -11,6 +11,7 @@ export default function present(share: Share, isAdmin: boolean = false) {
published: share.published,
url: `${share.team.url}/share/${share.id}`,
createdBy: presentUser(share.user),
+ includeChildDocuments: share.includeChildDocuments,
lastAccessedAt: share.lastAccessedAt,
createdAt: share.createdAt,
updatedAt: share.updatedAt,
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 8d8acaf4..7306ee1c 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -3,17 +3,17 @@
"currently viewing": "currently viewing",
"previously edited": "previously edited",
"You": "You",
- "Trash": "Trash",
- "Archive": "Archive",
- "Drafts": "Drafts",
- "Templates": "Templates",
- "Deleted Collection": "Deleted Collection",
"Viewers": "Viewers",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Submenu": "Submenu",
+ "Trash": "Trash",
+ "Archive": "Archive",
+ "Drafts": "Drafts",
+ "Templates": "Templates",
+ "Deleted Collection": "Deleted Collection",
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
@@ -290,13 +290,18 @@
"New from template": "New from template",
"Publish": "Publish",
"Publishing": "Publishing",
+ "Nested documents": "Nested documents",
"Anyone with the link <1>1>can view this document": "Anyone with the link <1>1>can view this document",
"Share": "Share",
"Share this document": "Share this document",
+ "This document is shared because the parent {{ documentTitle }} is publicly shared": "This document is shared because the parent {{ documentTitle }} is publicly shared",
"Publish to internet": "Publish to internet",
"Anyone with the link can view this document": "Anyone with the link can view this document",
- "Only team members with access can view": "Only team members with access can view",
+ "Only team members with permission can view": "Only team members with permission can view",
"The shared link was last accessed {{ timeAgo }}.": "The shared link was last accessed {{ timeAgo }}.",
+ "Share nested documents": "Share nested documents",
+ "Nested documents are publicly available": "Nested documents are publicly available",
+ "Nested documents are not shared": "Nested documents are not shared",
"Are you sure you want to delete the {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?",
"Are you sure about that? Deleting the {{ documentTitle }} document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the {{ documentTitle }} document will delete all of its history and any nested documents.",
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",