feat: Nested document sharing (#2075)

* migration

* frontend routing, api permissioning

* feat: apiVersion=2

* feat: re-writing document links to point to share

* poc nested documents on share links

* fix: nested shareId permissions

* ui and language tweaks, comments

* breadcrumbs

* Add icons to reference list items

* refactor: Breadcrumb component

* tweaks

* Add shared parent note
This commit is contained in:
Tom Moor
2021-05-22 19:34:05 -07:00
committed by GitHub
parent dc4b5588b7
commit 44920a25f4
31 changed files with 1134 additions and 282 deletions

View File

@ -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 (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
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: <BreadcrumbMenu items={overflowItems} />,
});
}
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) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Flex justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
{topLevelItems.map((item, index) => (
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
</React.Fragment>
))}
{children}
</Flex>
);
};
}
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;

View File

@ -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: <TrashIcon color="currentColor" />,
title: t("Trash"),
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
title: t("Archive"),
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
title: t("Drafts"),
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
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: <CollectionIcon collection={collection} expanded />,
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) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
};
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);

View File

@ -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({
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
<DocumentBreadcrumb document={document} onlyText />
</strong>
</span>
)}

View File

@ -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(

View File

@ -144,9 +144,10 @@ class SocketProvider extends React.Component<Props> {
// 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);

View File

@ -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;
}

View File

@ -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<any>,
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) {
<>
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);

View File

@ -10,6 +10,7 @@ class Share extends BaseModel {
documentTitle: string;
documentUrl: string;
lastAccessedAt: ?string;
includeChildDocuments: boolean;
createdBy: User;
createdAt: string;
updatedAt: string;

View File

@ -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() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
</Authenticated>

View File

@ -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<Props> {
@observable sharedTree: ?NavigationNode;
@observable document: ?Document;
@observable revision: ?Revision;
@observable error: ?Error;
@ -89,7 +89,7 @@ class DataLoader extends React.Component<Props> {
// 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<Props> {
}
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<Props> {
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
);

View File

@ -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<Props> {
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<Props> {
)}
<Header
document={document}
isShare={isShare}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly}
@ -377,6 +380,7 @@ class DocumentScene extends React.Component<Props> {
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<Props> {
<Editor
id={document.id}
innerRef={this.editor}
isShare={isShare}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
@ -442,6 +446,15 @@ class DocumentScene extends React.Component<Props> {
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />

View File

@ -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<Props> {
title,
onChangeTitle,
isDraft,
isShare,
shareId,
readOnly,
innerRef,
children,
@ -118,7 +118,7 @@ class DocumentEditor extends React.Component<Props> {
$isStarred={document.isStarred}
>
<span>{normalizedTitle}</span>{" "}
{!isShare && <StarButton document={document} size={32} />}
{!shareId && <StarButton document={document} size={32} />}
</Title>
) : (
<Title
@ -144,11 +144,12 @@ class DocumentEditor extends React.Component<Props> {
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
shareId={shareId}
grow
{...rest}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent && !isShare && readOnly && (
{this.activeLinkEvent && !shareId && readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
event={this.activeLinkEvent}

View File

@ -13,23 +13,26 @@ import styled from "styled-components";
import Document from "models/Document";
import { Action, Separator } from "components/Actions";
import Badge from "components/Badge";
import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button";
import Collaborators from "components/Collaborators";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Header from "components/Header";
import Tooltip from "components/Tooltip";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
isShare: boolean,
sharedTree: ?NavigationNode,
shareId: ?string,
isDraft: boolean,
isEditing: boolean,
isRevision: boolean,
@ -47,7 +50,7 @@ type Props = {|
function DocumentHeader({
document,
isShare,
shareId,
isEditing,
isDraft,
isPublishing,
@ -55,6 +58,7 @@ function DocumentHeader({
isSaving,
savingIsDisabled,
publishingIsDisabled,
sharedTree,
onSave,
}: Props) {
const { t } = useTranslation();
@ -115,11 +119,19 @@ function DocumentHeader({
</Action>
);
if (isShare) {
if (shareId) {
return (
<Header
title={document.title}
breadcrumb={toc}
breadcrumb={
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{toc}
</PublicBreadcrumb>
}
actions={canEdit ? editAction : <div />}
/>
);
@ -129,14 +141,9 @@ function DocumentHeader({
<>
<Header
breadcrumb={
<Breadcrumb document={document}>
{!isEditing && (
<>
<Slash />
{toc}
</>
)}
</Breadcrumb>
<DocumentBreadcrumb document={document}>
{!isEditing && toc}
</DocumentBreadcrumb>
}
title={
<>

View File

@ -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 <Breadcrumb items={items} children={children} />;
};
export default PublicBreadcrumb;

View File

@ -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 (
<>
<Subheading>{t("Nested documents")}</Subheading>
{children.map((node) => (
<ReferenceListItem key={node.id} document={node} shareId={shareId} />
))}
</>
);
}
export default observer(PublicReferences);

View File

@ -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<Props> {
render() {
const { document, showCollection, anchor, ...rest } = this.props;
const StyledDocumentIcon = styled(DocumentIcon)`
margin-left: -4px;
color: ${(props) => props.theme.textSecondary};
`;
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 (
<DocumentLink
to={{
pathname: document.url,
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: { title: document.title },
}}
{...rest}
>
<Title>{document.title}</Title>
<Title>
{document.emoji ? (
<Emoji>{document.emoji}</Emoji>
) : (
<StyledDocumentIcon color="currentColor" />
)}{" "}
{document.emoji
? document.title.replace(new RegExp(`^${document.emoji}`), "")
: document.title}
</Title>
{document.updatedBy && (
<DocumentMeta document={document} showCollection={showCollection} />
)}
</DocumentLink>
);
}
}
export default ReferenceListItem;
export default observer(ReferenceListItem);

View File

@ -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) {
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onSubmit={popover.hide}
/>
</Popover>

View File

@ -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<?TimeoutID>();
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 (
<>
<Heading>
{share && share.published ? (
{isPubliclyShared ? (
<GlobeIcon size={28} color="currentColor" />
) : (
<PadlockIcon size={28} color="currentColor" />
@ -76,20 +90,30 @@ function DocumentShare({ document, share, onSubmit }: Props) {
{t("Share this document")}
</Heading>
{sharedParent && (
<Notice>
<Trans
defaults="This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared"
values={{ documentTitle: sharedParent.documentTitle }}
components={{ em: <strong /> }}
/>
</Notice>
)}
{canPublish && (
<PrivacySwitch>
<SwitchWrapper>
<Switch
id="published"
label={t("Publish to internet")}
onChange={handlePublishedChange}
checked={share ? share.published : false}
disabled={!share || isSaving}
disabled={!share}
/>
<Privacy>
<PrivacyText>
<SwitchLabel>
<SwitchText>
{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) {
})}
</>
)}
</PrivacyText>
</Privacy>
</PrivacySwitch>
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
{share && share.published && (
<SwitchWrapper>
<Switch
id="includeChildDocuments"
label={t("Share nested documents")}
onChange={handleChildDocumentsChange}
checked={share ? share.includeChildDocuments : false}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{share.includeChildDocuments
? t("Nested documents are publicly available")
: t("Nested documents are not shared")}
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
<Flex>
<InputLink
@ -130,7 +172,7 @@ const Heading = styled.h2`
margin-left: -4px;
`;
const PrivacySwitch = styled.div`
const SwitchWrapper = styled.div`
margin: 20px 0;
`;
@ -139,7 +181,7 @@ const InputLink = styled(Input)`
margin-right: 8px;
`;
const Privacy = styled(Flex)`
const SwitchLabel = styled(Flex)`
flex-align: center;
svg {
@ -147,9 +189,9 @@ const Privacy = styled(Flex)`
}
`;
const PrivacyText = styled(HelpText)`
const SwitchText = styled(HelpText)`
margin: 0;
font-size: 15px;
`;
export default observer(DocumentShare);
export default observer(SharePopover);

View File

@ -10,7 +10,12 @@ import BaseStore from "stores/BaseStore";
import RootStore from "stores/RootStore";
import Document from "models/Document";
import env from "env";
import type { FetchOptions, PaginationParams, SearchResult } from "types";
import type {
NavigationNode,
FetchOptions,
PaginationParams,
SearchResult,
} from "types";
import { client } from "utils/ApiClient";
type ImportOptions = {
@ -447,30 +452,30 @@ export default class DocumentsStore extends BaseStore<Document> {
fetch = async (
id: string,
options: FetchOptions = {}
): Promise<?Document> => {
): 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;
}

View File

@ -46,19 +46,42 @@ export default class SharesStore extends BaseStore<Share> {
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);
};
}

View File

@ -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(),

View File

@ -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({

View File

@ -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,6 +30,9 @@ router.post("shares.info", auth(), async (ctx) => {
revokedAt: { [Op.eq]: null },
},
});
// Deprecated API response returns just the share for the current documentId
if (apiVersion !== 2) {
if (!share || !share.document) {
return (ctx.response.status = 204);
}
@ -39,6 +43,50 @@ router.post("shares.info", auth(), async (ctx) => {
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);
}
ctx.body = {
data: {
shares: shares.map((share) => presentShare(share, user.isAdmin)),
},
policies: presentPolicies(user, shares),
};
});
router.post("shares.list", auth(), pagination(), async (ctx) => {
@ -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);
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({

View File

@ -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({

View File

@ -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");
}
};

View File

@ -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

View File

@ -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();

View File

@ -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,

View File

@ -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,

View File

@ -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 <em>{{ documentTitle }}</em> is publicly shared": "This document is shared because the parent <em>{{ documentTitle }}</em> 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 <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",