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:
@ -1,193 +1,87 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { observer } from "mobx-react";
|
import { GoToIcon } from "outline-icons";
|
||||||
import {
|
|
||||||
ArchiveIcon,
|
|
||||||
EditIcon,
|
|
||||||
GoToIcon,
|
|
||||||
ShapesIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Document from "models/Document";
|
|
||||||
import CollectionIcon from "components/CollectionIcon";
|
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import useStores from "hooks/useStores";
|
|
||||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||||
import { collectionUrl } from "utils/routeHelpers";
|
|
||||||
|
|
||||||
type Props = {|
|
type MenuItem = {|
|
||||||
document: Document,
|
icon?: React.Node,
|
||||||
children?: React.Node,
|
title: React.Node,
|
||||||
onlyText: boolean,
|
to?: string,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function Icon({ document }) {
|
type Props = {|
|
||||||
const { t } = useTranslation();
|
items: MenuItem[],
|
||||||
|
max?: number,
|
||||||
|
children?: React.Node,
|
||||||
|
highlightFirstItem?: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
if (document.isDeleted) {
|
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
|
||||||
return (
|
const totalItems = items.length;
|
||||||
<>
|
let topLevelItems: MenuItem[] = [...items];
|
||||||
<CategoryName to="/trash">
|
let overflowItems;
|
||||||
<TrashIcon color="currentColor" />
|
|
||||||
|
|
||||||
<span>{t("Trash")}</span>
|
|
||||||
</CategoryName>
|
|
||||||
<Slash />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (document.isArchived) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CategoryName to="/archive">
|
|
||||||
<ArchiveIcon color="currentColor" />
|
|
||||||
|
|
||||||
<span>{t("Archive")}</span>
|
|
||||||
</CategoryName>
|
|
||||||
<Slash />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (document.isDraft) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CategoryName to="/drafts">
|
|
||||||
<EditIcon color="currentColor" />
|
|
||||||
|
|
||||||
<span>{t("Drafts")}</span>
|
|
||||||
</CategoryName>
|
|
||||||
<Slash />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (document.isTemplate) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CategoryName to="/templates">
|
|
||||||
<ShapesIcon color="currentColor" />
|
|
||||||
|
|
||||||
<span>{t("Templates")}</span>
|
|
||||||
</CategoryName>
|
|
||||||
<Slash />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Breadcrumb = ({ document, children, onlyText }: Props) => {
|
// chop middle breadcrumbs and present a "..." menu instead
|
||||||
const { collections } = useStores();
|
if (totalItems > max) {
|
||||||
const { t } = useTranslation();
|
const halfMax = Math.floor(max / 2);
|
||||||
|
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||||
if (!collections.isLoaded) {
|
topLevelItems.splice(halfMax, 0, {
|
||||||
return;
|
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 (
|
return (
|
||||||
<Flex justify="flex-start" align="center">
|
<Flex justify="flex-start" align="center">
|
||||||
<Icon document={document} />
|
{topLevelItems.map((item, index) => (
|
||||||
<CollectionName to={collectionUrl(collection.id)}>
|
<React.Fragment key={item.to || index}>
|
||||||
<CollectionIcon collection={collection} expanded />
|
{item.icon}
|
||||||
|
{item.to ? (
|
||||||
<span>{collection.name}</span>
|
<Item
|
||||||
</CollectionName>
|
to={item.to}
|
||||||
{isNestedDocument && (
|
$withIcon={!!item.icon}
|
||||||
<>
|
$highlight={highlightFirstItem && index === 0}
|
||||||
<Slash /> <BreadcrumbMenu path={menuPath} />
|
>
|
||||||
</>
|
{item.title}
|
||||||
)}
|
</Item>
|
||||||
{lastPath && (
|
) : (
|
||||||
<>
|
item.title
|
||||||
<Slash />{" "}
|
)}
|
||||||
<Crumb to={lastPath.url} title={lastPath.title}>
|
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
|
||||||
{lastPath.title}
|
</React.Fragment>
|
||||||
</Crumb>
|
))}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const Slash = styled(GoToIcon)`
|
const Slash = styled(GoToIcon)`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
fill: ${(props) => props.theme.divider};
|
fill: ${(props) => props.theme.divider};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SmallSlash = styled(GoToIcon)`
|
const Item = styled(Link)`
|
||||||
width: 12px;
|
display: flex;
|
||||||
height: 12px;
|
flex-shrink: 1;
|
||||||
vertical-align: middle;
|
min-width: 0;
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
fill: ${(props) => props.theme.slate};
|
|
||||||
opacity: 0.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Crumb = styled(Link)`
|
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||||
|
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CollectionName = styled(Link)`
|
export default Breadcrumb;
|
||||||
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);
|
|
||||||
|
137
app/components/DocumentBreadcrumb.js
Normal file
137
app/components/DocumentBreadcrumb.js
Normal 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);
|
@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import Breadcrumb from "components/Breadcrumb";
|
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import Time from "components/Time";
|
import Time from "components/Time";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
@ -142,7 +142,7 @@ function DocumentMeta({
|
|||||||
<span>
|
<span>
|
||||||
{t("in")}
|
{t("in")}
|
||||||
<strong>
|
<strong>
|
||||||
<Breadcrumb document={document} onlyText />
|
<DocumentBreadcrumb document={document} onlyText />
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -27,6 +27,7 @@ export type Props = {|
|
|||||||
grow?: boolean,
|
grow?: boolean,
|
||||||
disableEmbeds?: boolean,
|
disableEmbeds?: boolean,
|
||||||
ui?: UiStore,
|
ui?: UiStore,
|
||||||
|
shareId?: ?string,
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
template?: boolean,
|
template?: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
@ -55,7 +56,7 @@ type PropsWithRef = Props & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Editor(props: PropsWithRef) {
|
function Editor(props: PropsWithRef) {
|
||||||
const { id, ui, history } = props;
|
const { id, ui, shareId, history } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isPrinting = useMediaQuery("print");
|
const isPrinting = useMediaQuery("print");
|
||||||
|
|
||||||
@ -89,12 +90,16 @@ function Editor(props: PropsWithRef) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shareId) {
|
||||||
|
navigateTo = `/share/${shareId}${navigateTo}`;
|
||||||
|
}
|
||||||
|
|
||||||
history.push(navigateTo);
|
history.push(navigateTo);
|
||||||
} else if (href) {
|
} else if (href) {
|
||||||
window.open(href, "_blank");
|
window.open(href, "_blank");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[history]
|
[history, shareId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onShowToast = React.useCallback(
|
const onShowToast = React.useCallback(
|
||||||
|
@ -144,9 +144,10 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
|
|
||||||
// otherwise, grab the latest version of the document
|
// otherwise, grab the latest version of the document
|
||||||
try {
|
try {
|
||||||
document = await documents.fetch(documentId, {
|
const response = await documents.fetch(documentId, {
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
|
document = response.document;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||||
documents.remove(documentId);
|
documents.remove(documentId);
|
||||||
|
@ -36,7 +36,7 @@ export default function useImportDocument(
|
|||||||
const redirect = files.length === 1;
|
const redirect = files.length === 1;
|
||||||
|
|
||||||
if (documentId && !collectionId) {
|
if (documentId && !collectionId) {
|
||||||
const document = await documents.fetch(documentId);
|
const { document } = await documents.fetch(documentId);
|
||||||
invariant(document, "Document not available");
|
invariant(document, "Document not available");
|
||||||
cId = document.collectionId;
|
cId = document.collectionId;
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,17 @@ import ContextMenu from "components/ContextMenu";
|
|||||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||||
import Template from "components/ContextMenu/Template";
|
import Template from "components/ContextMenu/Template";
|
||||||
|
|
||||||
|
type MenuItem = {|
|
||||||
|
icon?: React.Node,
|
||||||
|
title: React.Node,
|
||||||
|
to?: string,
|
||||||
|
|};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
path: Array<any>,
|
items: MenuItem[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BreadcrumbMenu({ path }: Props) {
|
export default function BreadcrumbMenu({ items }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const menu = useMenuState({
|
const menu = useMenuState({
|
||||||
modal: true,
|
modal: true,
|
||||||
@ -21,13 +27,7 @@ export default function BreadcrumbMenu({ path }: Props) {
|
|||||||
<>
|
<>
|
||||||
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
|
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
|
||||||
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
||||||
<Template
|
<Template {...menu} items={items} />
|
||||||
{...menu}
|
|
||||||
items={path.map((item) => ({
|
|
||||||
title: item.title,
|
|
||||||
to: item.url,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -10,6 +10,7 @@ class Share extends BaseModel {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
documentUrl: string;
|
documentUrl: string;
|
||||||
lastAccessedAt: ?string;
|
lastAccessedAt: ?string;
|
||||||
|
includeChildDocuments: boolean;
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
@ -4,6 +4,7 @@ import { Switch } from "react-router-dom";
|
|||||||
import DelayedMount from "components/DelayedMount";
|
import DelayedMount from "components/DelayedMount";
|
||||||
import FullscreenLoading from "components/FullscreenLoading";
|
import FullscreenLoading from "components/FullscreenLoading";
|
||||||
import Route from "components/ProfiledRoute";
|
import Route from "components/ProfiledRoute";
|
||||||
|
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||||
|
|
||||||
const Authenticated = React.lazy(() => import("components/Authenticated"));
|
const Authenticated = React.lazy(() => import("components/Authenticated"));
|
||||||
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
|
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
|
||||||
@ -25,6 +26,11 @@ export default function Routes() {
|
|||||||
<Route exact path="/create" component={Login} />
|
<Route exact path="/create" component={Login} />
|
||||||
<Route exact path="/logout" component={Logout} />
|
<Route exact path="/logout" component={Logout} />
|
||||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={`/share/:shareId/doc/${slug}`}
|
||||||
|
component={KeyedDocument}
|
||||||
|
/>
|
||||||
<Authenticated>
|
<Authenticated>
|
||||||
<AuthenticatedRoutes />
|
<AuthenticatedRoutes />
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
|
@ -22,11 +22,10 @@ import DocumentComponent from "./Document";
|
|||||||
import HideSidebar from "./HideSidebar";
|
import HideSidebar from "./HideSidebar";
|
||||||
import Loading from "./Loading";
|
import Loading from "./Loading";
|
||||||
import SocketPresence from "./SocketPresence";
|
import SocketPresence from "./SocketPresence";
|
||||||
import { type LocationWithState } from "types";
|
import { type LocationWithState, type NavigationNode } from "types";
|
||||||
import { NotFoundError, OfflineError } from "utils/errors";
|
import { NotFoundError, OfflineError } from "utils/errors";
|
||||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||||
import { isInternalUrl } from "utils/urls";
|
import { isInternalUrl } from "utils/urls";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
match: Match,
|
match: Match,
|
||||||
location: LocationWithState,
|
location: LocationWithState,
|
||||||
@ -41,6 +40,7 @@ type Props = {|
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
class DataLoader extends React.Component<Props> {
|
class DataLoader extends React.Component<Props> {
|
||||||
|
@observable sharedTree: ?NavigationNode;
|
||||||
@observable document: ?Document;
|
@observable document: ?Document;
|
||||||
@observable revision: ?Revision;
|
@observable revision: ?Revision;
|
||||||
@observable error: ?Error;
|
@observable error: ?Error;
|
||||||
@ -89,7 +89,7 @@ class DataLoader extends React.Component<Props> {
|
|||||||
// search for exact internal document
|
// search for exact internal document
|
||||||
const slug = parseDocumentSlug(term);
|
const slug = parseDocumentSlug(term);
|
||||||
try {
|
try {
|
||||||
const document = await this.props.documents.fetch(slug);
|
const { document } = await this.props.documents.fetch(slug);
|
||||||
const time = distanceInWordsToNow(document.updatedAt, {
|
const time = distanceInWordsToNow(document.updatedAt, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
});
|
});
|
||||||
@ -159,10 +159,13 @@ class DataLoader extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.document = await this.props.documents.fetch(documentSlug, {
|
const response = await this.props.documents.fetch(documentSlug, {
|
||||||
shareId,
|
shareId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.document = response.document;
|
||||||
|
this.sharedTree = response.sharedTree;
|
||||||
|
|
||||||
if (revisionId && revisionId !== "latest") {
|
if (revisionId && revisionId !== "latest") {
|
||||||
await this.loadRevision();
|
await this.loadRevision();
|
||||||
} else {
|
} else {
|
||||||
@ -249,6 +252,7 @@ class DataLoader extends React.Component<Props> {
|
|||||||
readOnly={!this.isEditing || !abilities.update || document.isArchived}
|
readOnly={!this.isEditing || !abilities.update || document.isArchived}
|
||||||
onSearchLink={this.onSearchLink}
|
onSearchLink={this.onSearchLink}
|
||||||
onCreateLink={this.onCreateLink}
|
onCreateLink={this.onCreateLink}
|
||||||
|
sharedTree={this.sharedTree}
|
||||||
/>
|
/>
|
||||||
</SocketPresence>
|
</SocketPresence>
|
||||||
);
|
);
|
||||||
|
@ -29,8 +29,9 @@ import Editor from "./Editor";
|
|||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||||
import MarkAsViewed from "./MarkAsViewed";
|
import MarkAsViewed from "./MarkAsViewed";
|
||||||
|
import PublicReferences from "./PublicReferences";
|
||||||
import References from "./References";
|
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 { isCustomDomain } from "utils/domains";
|
||||||
import { emojiToUrl } from "utils/emoji";
|
import { emojiToUrl } from "utils/emoji";
|
||||||
import { meta } from "utils/keyboard";
|
import { meta } from "utils/keyboard";
|
||||||
@ -57,6 +58,7 @@ type Props = {
|
|||||||
match: Match,
|
match: Match,
|
||||||
history: RouterHistory,
|
history: RouterHistory,
|
||||||
location: LocationWithState,
|
location: LocationWithState,
|
||||||
|
sharedTree: ?NavigationNode,
|
||||||
abilities: Object,
|
abilities: Object,
|
||||||
document: Document,
|
document: Document,
|
||||||
revision: Revision,
|
revision: Revision,
|
||||||
@ -311,7 +313,8 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
match,
|
match,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const team = auth.team;
|
const team = auth.team;
|
||||||
const isShare = !!match.params.shareId;
|
const { shareId } = match.params;
|
||||||
|
const isShare = !!shareId;
|
||||||
|
|
||||||
const value = revision ? revision.text : document.text;
|
const value = revision ? revision.text : document.text;
|
||||||
const injectTemplate = document.injectTemplate;
|
const injectTemplate = document.injectTemplate;
|
||||||
@ -367,7 +370,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
<Header
|
<Header
|
||||||
document={document}
|
document={document}
|
||||||
isShare={isShare}
|
shareId={shareId}
|
||||||
isRevision={!!revision}
|
isRevision={!!revision}
|
||||||
isDraft={document.isDraft}
|
isDraft={document.isDraft}
|
||||||
isEditing={!readOnly}
|
isEditing={!readOnly}
|
||||||
@ -377,6 +380,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
document.isSaving || this.isPublishing || this.isEmpty
|
document.isSaving || this.isPublishing || this.isEmpty
|
||||||
}
|
}
|
||||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||||
|
sharedTree={this.props.sharedTree}
|
||||||
goBack={this.goBack}
|
goBack={this.goBack}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
/>
|
/>
|
||||||
@ -420,7 +424,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
<Editor
|
<Editor
|
||||||
id={document.id}
|
id={document.id}
|
||||||
innerRef={this.editor}
|
innerRef={this.editor}
|
||||||
isShare={isShare}
|
shareId={shareId}
|
||||||
isDraft={document.isDraft}
|
isDraft={document.isDraft}
|
||||||
template={document.isTemplate}
|
template={document.isTemplate}
|
||||||
key={[injectTemplate, disableEmbeds].join("-")}
|
key={[injectTemplate, disableEmbeds].join("-")}
|
||||||
@ -442,6 +446,15 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||||
ui={this.props.ui}
|
ui={this.props.ui}
|
||||||
>
|
>
|
||||||
|
{shareId && (
|
||||||
|
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||||
|
<PublicReferences
|
||||||
|
shareId={shareId}
|
||||||
|
documentId={document.id}
|
||||||
|
sharedTree={this.props.sharedTree}
|
||||||
|
/>
|
||||||
|
</ReferencesWrapper>
|
||||||
|
)}
|
||||||
{!isShare && !revision && (
|
{!isShare && !revision && (
|
||||||
<>
|
<>
|
||||||
<MarkAsViewed document={document} />
|
<MarkAsViewed document={document} />
|
||||||
|
@ -24,7 +24,7 @@ type Props = {|
|
|||||||
title: string,
|
title: string,
|
||||||
document: Document,
|
document: Document,
|
||||||
isDraft: boolean,
|
isDraft: boolean,
|
||||||
isShare: boolean,
|
shareId: ?string,
|
||||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||||
innerRef: { current: any },
|
innerRef: { current: any },
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
@ -97,7 +97,7 @@ class DocumentEditor extends React.Component<Props> {
|
|||||||
title,
|
title,
|
||||||
onChangeTitle,
|
onChangeTitle,
|
||||||
isDraft,
|
isDraft,
|
||||||
isShare,
|
shareId,
|
||||||
readOnly,
|
readOnly,
|
||||||
innerRef,
|
innerRef,
|
||||||
children,
|
children,
|
||||||
@ -118,7 +118,7 @@ class DocumentEditor extends React.Component<Props> {
|
|||||||
$isStarred={document.isStarred}
|
$isStarred={document.isStarred}
|
||||||
>
|
>
|
||||||
<span>{normalizedTitle}</span>{" "}
|
<span>{normalizedTitle}</span>{" "}
|
||||||
{!isShare && <StarButton document={document} size={32} />}
|
{!shareId && <StarButton document={document} size={32} />}
|
||||||
</Title>
|
</Title>
|
||||||
) : (
|
) : (
|
||||||
<Title
|
<Title
|
||||||
@ -144,11 +144,12 @@ class DocumentEditor extends React.Component<Props> {
|
|||||||
onHoverLink={this.handleLinkActive}
|
onHoverLink={this.handleLinkActive}
|
||||||
scrollTo={window.location.hash}
|
scrollTo={window.location.hash}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
shareId={shareId}
|
||||||
grow
|
grow
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||||
{this.activeLinkEvent && !isShare && readOnly && (
|
{this.activeLinkEvent && !shareId && readOnly && (
|
||||||
<HoverPreview
|
<HoverPreview
|
||||||
node={this.activeLinkEvent.target}
|
node={this.activeLinkEvent.target}
|
||||||
event={this.activeLinkEvent}
|
event={this.activeLinkEvent}
|
||||||
|
@ -13,23 +13,26 @@ import styled from "styled-components";
|
|||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import { Action, Separator } from "components/Actions";
|
import { Action, Separator } from "components/Actions";
|
||||||
import Badge from "components/Badge";
|
import Badge from "components/Badge";
|
||||||
import Breadcrumb, { Slash } from "components/Breadcrumb";
|
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
import Collaborators from "components/Collaborators";
|
import Collaborators from "components/Collaborators";
|
||||||
|
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||||
import Header from "components/Header";
|
import Header from "components/Header";
|
||||||
import Tooltip from "components/Tooltip";
|
import Tooltip from "components/Tooltip";
|
||||||
|
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
import useMobile from "hooks/useMobile";
|
import useMobile from "hooks/useMobile";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
import DocumentMenu from "menus/DocumentMenu";
|
import DocumentMenu from "menus/DocumentMenu";
|
||||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||||
import TemplatesMenu from "menus/TemplatesMenu";
|
import TemplatesMenu from "menus/TemplatesMenu";
|
||||||
|
import { type NavigationNode } from "types";
|
||||||
import { metaDisplay } from "utils/keyboard";
|
import { metaDisplay } from "utils/keyboard";
|
||||||
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
document: Document,
|
document: Document,
|
||||||
isShare: boolean,
|
sharedTree: ?NavigationNode,
|
||||||
|
shareId: ?string,
|
||||||
isDraft: boolean,
|
isDraft: boolean,
|
||||||
isEditing: boolean,
|
isEditing: boolean,
|
||||||
isRevision: boolean,
|
isRevision: boolean,
|
||||||
@ -47,7 +50,7 @@ type Props = {|
|
|||||||
|
|
||||||
function DocumentHeader({
|
function DocumentHeader({
|
||||||
document,
|
document,
|
||||||
isShare,
|
shareId,
|
||||||
isEditing,
|
isEditing,
|
||||||
isDraft,
|
isDraft,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
@ -55,6 +58,7 @@ function DocumentHeader({
|
|||||||
isSaving,
|
isSaving,
|
||||||
savingIsDisabled,
|
savingIsDisabled,
|
||||||
publishingIsDisabled,
|
publishingIsDisabled,
|
||||||
|
sharedTree,
|
||||||
onSave,
|
onSave,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -115,11 +119,19 @@ function DocumentHeader({
|
|||||||
</Action>
|
</Action>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isShare) {
|
if (shareId) {
|
||||||
return (
|
return (
|
||||||
<Header
|
<Header
|
||||||
title={document.title}
|
title={document.title}
|
||||||
breadcrumb={toc}
|
breadcrumb={
|
||||||
|
<PublicBreadcrumb
|
||||||
|
documentId={document.id}
|
||||||
|
shareId={shareId}
|
||||||
|
sharedTree={sharedTree}
|
||||||
|
>
|
||||||
|
{toc}
|
||||||
|
</PublicBreadcrumb>
|
||||||
|
}
|
||||||
actions={canEdit ? editAction : <div />}
|
actions={canEdit ? editAction : <div />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -129,14 +141,9 @@ function DocumentHeader({
|
|||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
breadcrumb={
|
breadcrumb={
|
||||||
<Breadcrumb document={document}>
|
<DocumentBreadcrumb document={document}>
|
||||||
{!isEditing && (
|
{!isEditing && toc}
|
||||||
<>
|
</DocumentBreadcrumb>
|
||||||
<Slash />
|
|
||||||
{toc}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Breadcrumb>
|
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
|
54
app/scenes/Document/components/PublicBreadcrumb.js
Normal file
54
app/scenes/Document/components/PublicBreadcrumb.js
Normal 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;
|
57
app/scenes/Document/components/PublicReferences.js
Normal file
57
app/scenes/Document/components/PublicReferences.js
Normal 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);
|
@ -1,5 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { DocumentIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
@ -8,6 +9,7 @@ import DocumentMeta from "components/DocumentMeta";
|
|||||||
import type { NavigationNode } from "types";
|
import type { NavigationNode } from "types";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
|
shareId?: string,
|
||||||
document: Document | NavigationNode,
|
document: Document | NavigationNode,
|
||||||
anchor?: string,
|
anchor?: string,
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
@ -31,6 +33,8 @@ const DocumentLink = styled(Link)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const Title = styled.h3`
|
const Title = styled.h3`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@ -43,27 +47,52 @@ const Title = styled.h3`
|
|||||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@observer
|
const StyledDocumentIcon = styled(DocumentIcon)`
|
||||||
class ReferenceListItem extends React.Component<Props> {
|
margin-left: -4px;
|
||||||
render() {
|
color: ${(props) => props.theme.textSecondary};
|
||||||
const { document, showCollection, anchor, ...rest } = this.props;
|
`;
|
||||||
|
|
||||||
return (
|
const Emoji = styled.span`
|
||||||
<DocumentLink
|
display: inline-flex;
|
||||||
to={{
|
align-items: center;
|
||||||
pathname: document.url,
|
justify-content: center;
|
||||||
hash: anchor ? `d-${anchor}` : undefined,
|
margin-left: -4px;
|
||||||
state: { title: document.title },
|
font-size: 16px;
|
||||||
}}
|
width: 24px;
|
||||||
{...rest}
|
height: 24px;
|
||||||
>
|
`;
|
||||||
<Title>{document.title}</Title>
|
|
||||||
{document.updatedBy && (
|
function ReferenceListItem({
|
||||||
<DocumentMeta document={document} showCollection={showCollection} />
|
document,
|
||||||
)}
|
showCollection,
|
||||||
</DocumentLink>
|
anchor,
|
||||||
);
|
shareId,
|
||||||
}
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<DocumentLink
|
||||||
|
to={{
|
||||||
|
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
|
||||||
|
hash: anchor ? `d-${anchor}` : undefined,
|
||||||
|
state: { title: document.title },
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<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);
|
||||||
|
@ -19,7 +19,9 @@ function ShareButton({ document }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { shares } = useStores();
|
const { shares } = useStores();
|
||||||
const share = shares.getByDocumentId(document.id);
|
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({
|
const popover = usePopoverState({
|
||||||
gutter: 0,
|
gutter: 0,
|
||||||
placement: "bottom-end",
|
placement: "bottom-end",
|
||||||
@ -57,6 +59,7 @@ function ShareButton({ document }: Props) {
|
|||||||
<SharePopover
|
<SharePopover
|
||||||
document={document}
|
document={document}
|
||||||
share={share}
|
share={share}
|
||||||
|
sharedParent={sharedParent}
|
||||||
onSubmit={popover.hide}
|
onSubmit={popover.hide}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
@ -4,7 +4,7 @@ import invariant from "invariant";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import Share from "models/Share";
|
import Share from "models/Share";
|
||||||
@ -13,23 +13,25 @@ import CopyToClipboard from "components/CopyToClipboard";
|
|||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import HelpText from "components/HelpText";
|
import HelpText from "components/HelpText";
|
||||||
import Input from "components/Input";
|
import Input from "components/Input";
|
||||||
|
import Notice from "components/Notice";
|
||||||
import Switch from "components/Switch";
|
import Switch from "components/Switch";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
document: Document,
|
document: Document,
|
||||||
share: Share,
|
share: Share,
|
||||||
|
sharedParent: ?Share,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function DocumentShare({ document, share, onSubmit }: Props) {
|
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { policies, shares, ui } = useStores();
|
const { policies, shares, ui } = useStores();
|
||||||
const [isCopied, setIsCopied] = React.useState(false);
|
const [isCopied, setIsCopied] = React.useState(false);
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
|
||||||
const timeout = React.useRef<?TimeoutID>();
|
const timeout = React.useRef<?TimeoutID>();
|
||||||
const can = policies.abilities(share ? share.id : "");
|
const can = policies.abilities(share ? share.id : "");
|
||||||
const canPublish = can.update && !document.isTemplate;
|
const canPublish = can.update && !document.isTemplate;
|
||||||
|
const isPubliclyShared = (share && share.published) || sharedParent;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
document.share();
|
document.share();
|
||||||
@ -41,14 +43,26 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
|||||||
const share = shares.getByDocumentId(document.id);
|
const share = shares.getByDocumentId(document.id);
|
||||||
invariant(share, "Share must exist");
|
invariant(share, "Share must exist");
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await share.save({ published: event.currentTarget.checked });
|
await share.save({ published: event.currentTarget.checked });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.showToast(err.message, { type: "error" });
|
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]
|
[document.id, shares, ui]
|
||||||
@ -68,7 +82,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading>
|
<Heading>
|
||||||
{share && share.published ? (
|
{isPubliclyShared ? (
|
||||||
<GlobeIcon size={28} color="currentColor" />
|
<GlobeIcon size={28} color="currentColor" />
|
||||||
) : (
|
) : (
|
||||||
<PadlockIcon size={28} color="currentColor" />
|
<PadlockIcon size={28} color="currentColor" />
|
||||||
@ -76,20 +90,30 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
|||||||
{t("Share this document")}
|
{t("Share this document")}
|
||||||
</Heading>
|
</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 && (
|
{canPublish && (
|
||||||
<PrivacySwitch>
|
<SwitchWrapper>
|
||||||
<Switch
|
<Switch
|
||||||
id="published"
|
id="published"
|
||||||
label={t("Publish to internet")}
|
label={t("Publish to internet")}
|
||||||
onChange={handlePublishedChange}
|
onChange={handlePublishedChange}
|
||||||
checked={share ? share.published : false}
|
checked={share ? share.published : false}
|
||||||
disabled={!share || isSaving}
|
disabled={!share}
|
||||||
/>
|
/>
|
||||||
<Privacy>
|
<SwitchLabel>
|
||||||
<PrivacyText>
|
<SwitchText>
|
||||||
{share.published
|
{share.published
|
||||||
? t("Anyone with the link can view this document")
|
? 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 && (
|
{share.lastAccessedAt && (
|
||||||
<>
|
<>
|
||||||
.{" "}
|
.{" "}
|
||||||
@ -100,9 +124,27 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PrivacyText>
|
</SwitchText>
|
||||||
</Privacy>
|
</SwitchLabel>
|
||||||
</PrivacySwitch>
|
</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>
|
<Flex>
|
||||||
<InputLink
|
<InputLink
|
||||||
@ -130,7 +172,7 @@ const Heading = styled.h2`
|
|||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PrivacySwitch = styled.div`
|
const SwitchWrapper = styled.div`
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -139,7 +181,7 @@ const InputLink = styled(Input)`
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Privacy = styled(Flex)`
|
const SwitchLabel = styled(Flex)`
|
||||||
flex-align: center;
|
flex-align: center;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@ -147,9 +189,9 @@ const Privacy = styled(Flex)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PrivacyText = styled(HelpText)`
|
const SwitchText = styled(HelpText)`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(DocumentShare);
|
export default observer(SharePopover);
|
||||||
|
@ -10,7 +10,12 @@ import BaseStore from "stores/BaseStore";
|
|||||||
import RootStore from "stores/RootStore";
|
import RootStore from "stores/RootStore";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import env from "env";
|
import env from "env";
|
||||||
import type { FetchOptions, PaginationParams, SearchResult } from "types";
|
import type {
|
||||||
|
NavigationNode,
|
||||||
|
FetchOptions,
|
||||||
|
PaginationParams,
|
||||||
|
SearchResult,
|
||||||
|
} from "types";
|
||||||
import { client } from "utils/ApiClient";
|
import { client } from "utils/ApiClient";
|
||||||
|
|
||||||
type ImportOptions = {
|
type ImportOptions = {
|
||||||
@ -447,30 +452,30 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
fetch = async (
|
fetch = async (
|
||||||
id: string,
|
id: string,
|
||||||
options: FetchOptions = {}
|
options: FetchOptions = {}
|
||||||
): Promise<?Document> => {
|
): Promise<{ document: ?Document, sharedTree?: NavigationNode }> => {
|
||||||
if (!options.prefetch) this.isFetching = true;
|
if (!options.prefetch) this.isFetching = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
|
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
|
||||||
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
||||||
if (doc && policy && !options.force) {
|
if (doc && policy && !options.force) {
|
||||||
return doc;
|
return { document: doc };
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await client.post("/documents.info", {
|
const res = await client.post("/documents.info", {
|
||||||
id,
|
id,
|
||||||
shareId: options.shareId,
|
shareId: options.shareId,
|
||||||
|
apiVersion: 2,
|
||||||
});
|
});
|
||||||
invariant(res && res.data, "Document not available");
|
invariant(res && res.data, "Document not available");
|
||||||
|
|
||||||
this.addPolicies(res.policies);
|
this.addPolicies(res.policies);
|
||||||
this.add(res.data);
|
this.add(res.data.document);
|
||||||
|
|
||||||
runInAction("DocumentsStore#fetch", () => {
|
return {
|
||||||
this.isLoaded = true;
|
document: this.data.get(res.data.document.id),
|
||||||
});
|
sharedTree: res.data.sharedTree,
|
||||||
|
};
|
||||||
return this.data.get(res.data.id);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
}
|
}
|
||||||
|
@ -46,19 +46,42 @@ export default class SharesStore extends BaseStore<Share> {
|
|||||||
this.isFetching = true;
|
this.isFetching = true;
|
||||||
|
|
||||||
try {
|
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;
|
if (isUndefined(res)) return;
|
||||||
|
|
||||||
invariant(res && res.data, "Data should be available");
|
invariant(res && res.data, "Data should be available");
|
||||||
|
|
||||||
this.addPolicies(res.policies);
|
this.addPolicies(res.policies);
|
||||||
return this.add(res.data);
|
return res.data.shares.map(this.add);
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
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);
|
return find(this.orderedData, (share) => share.documentId === documentId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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 document;
|
||||||
|
let collection;
|
||||||
|
let share;
|
||||||
|
|
||||||
if (shareId) {
|
if (shareId) {
|
||||||
const share = await Share.findOne({
|
share = await Share.findOne({
|
||||||
where: {
|
where: {
|
||||||
revokedAt: { [Op.eq]: null },
|
revokedAt: { [Op.eq]: null },
|
||||||
id: shareId,
|
id: shareId,
|
||||||
@ -497,7 +503,20 @@ async function loadDocument({ id, shareId, user }) {
|
|||||||
throw new InvalidRequestError("Document could not be found for shareId");
|
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, {
|
document = await Document.findByPk(share.documentId, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
@ -506,15 +525,31 @@ async function loadDocument({ id, shareId, user }) {
|
|||||||
document = share.document;
|
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) {
|
if (!share.published) {
|
||||||
authorize(user, "read", document);
|
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) {
|
if (!collection.sharing) {
|
||||||
throw new AuthorizationError();
|
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);
|
const team = await Team.findByPk(document.teamId);
|
||||||
if (!team.sharing) {
|
if (!team.sharing) {
|
||||||
throw new AuthorizationError();
|
throw new AuthorizationError();
|
||||||
@ -535,21 +570,41 @@ async function loadDocument({ id, shareId, user }) {
|
|||||||
} else {
|
} else {
|
||||||
authorize(user, "read", document);
|
authorize(user, "read", document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collection = document.collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
return document;
|
return { document, share, collection };
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post("documents.info", auth({ required: false }), async (ctx) => {
|
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");
|
ctx.assertPresent(id || shareId, "id or shareId is required");
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const { user } = ctx.state;
|
||||||
const document = await loadDocument({ id, shareId, user });
|
const { document, share, collection } = await loadDocument({
|
||||||
|
id,
|
||||||
|
shareId,
|
||||||
|
user,
|
||||||
|
});
|
||||||
const isPublic = cannot(user, "read", document);
|
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 = {
|
ctx.body = {
|
||||||
data: await presentDocument(document, { isPublic }),
|
data,
|
||||||
policies: isPublic ? undefined : presentPolicies(user, [document]),
|
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");
|
ctx.assertPresent(id || shareId, "id or shareId is required");
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await loadDocument({ id, shareId, user });
|
const { document } = await loadDocument({ id, shareId, user });
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: document.toMarkdown(),
|
data: document.toMarkdown(),
|
||||||
|
@ -98,6 +98,109 @@ describe("#documents.info", () => {
|
|||||||
expect(share.lastAccessedAt).toBeTruthy();
|
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 () => {
|
it("should not return document from shareId if sharing is disabled for team", async () => {
|
||||||
const { document, team, user } = await seed();
|
const { document, team, user } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
|
@ -13,11 +13,12 @@ const { authorize } = policy;
|
|||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post("shares.info", auth(), async (ctx) => {
|
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");
|
ctx.assertUuid(id || documentId, "id or documentId is required");
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const share = await Share.findOne({
|
let shares = [];
|
||||||
|
let share = await Share.findOne({
|
||||||
where: id
|
where: id
|
||||||
? {
|
? {
|
||||||
id,
|
id,
|
||||||
@ -29,15 +30,62 @@ router.post("shares.info", auth(), async (ctx) => {
|
|||||||
revokedAt: { [Op.eq]: null },
|
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);
|
return (ctx.response.status = 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
authorize(user, "read", share);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentShare(share, user.isAdmin),
|
data: {
|
||||||
policies: presentPolicies(user, [share]),
|
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) => {
|
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.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);
|
const share = await Share.findByPk(id);
|
||||||
authorize(user, "update", share);
|
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 share.save();
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import TestServer from "fetch-test-server";
|
import TestServer from "fetch-test-server";
|
||||||
import app from "../app";
|
import app from "../app";
|
||||||
import { CollectionUser } from "../models";
|
import { CollectionUser } from "../models";
|
||||||
import { buildUser, buildShare } from "../test/factories";
|
import { buildUser, buildDocument, buildShare } from "../test/factories";
|
||||||
import { flushdb, seed } from "../test/support";
|
import { flushdb, seed } from "../test/support";
|
||||||
|
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
@ -260,7 +260,7 @@ describe("#shares.info", () => {
|
|||||||
expect(body.data.createdBy.id).toBe(user.id);
|
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 { user, document } = await seed();
|
||||||
const author = await buildUser({ teamId: user.teamId });
|
const author = await buildUser({ teamId: user.teamId });
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
@ -347,6 +347,135 @@ describe("#shares.info", () => {
|
|||||||
expect(res.status).toEqual(204);
|
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 () => {
|
it("should require authentication", async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async (queryInterface, Sequelize) => {
|
||||||
await queryInterface.addColumn("shares", "lastAccessedAt", {
|
await queryInterface.addColumn("shares", "lastAccessedAt", {
|
||||||
type: Sequelize.DATE,
|
type: Sequelize.DATE,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
});
|
});
|
||||||
|
15
server/migrations/20210426055334-nested-document-sharing.js
Normal file
15
server/migrations/20210426055334-nested-document-sharing.js
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
@ -375,6 +375,77 @@ Collection.prototype.deleteDocument = async function (document) {
|
|||||||
await document.deleteWithChildren();
|
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 (
|
Collection.prototype.removeDocumentInStructure = async function (
|
||||||
document,
|
document,
|
||||||
options
|
options
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
buildGroup,
|
buildGroup,
|
||||||
buildCollection,
|
buildCollection,
|
||||||
buildTeam,
|
buildTeam,
|
||||||
|
buildDocument,
|
||||||
} from "../test/factories";
|
} from "../test/factories";
|
||||||
import { flushdb, seed } from "../test/support";
|
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", () => {
|
describe("#addDocumentToStructure", () => {
|
||||||
test("should add as last element without index", async () => {
|
test("should add as last element without index", async () => {
|
||||||
const { collection } = await seed();
|
const { collection } = await seed();
|
||||||
|
@ -10,6 +10,7 @@ const Share = sequelize.define(
|
|||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
published: DataTypes.BOOLEAN,
|
published: DataTypes.BOOLEAN,
|
||||||
|
includeChildDocuments: DataTypes.BOOLEAN,
|
||||||
revokedAt: DataTypes.DATE,
|
revokedAt: DataTypes.DATE,
|
||||||
revokedById: DataTypes.UUID,
|
revokedById: DataTypes.UUID,
|
||||||
lastAccessedAt: DataTypes.DATE,
|
lastAccessedAt: DataTypes.DATE,
|
||||||
|
@ -11,6 +11,7 @@ export default function present(share: Share, isAdmin: boolean = false) {
|
|||||||
published: share.published,
|
published: share.published,
|
||||||
url: `${share.team.url}/share/${share.id}`,
|
url: `${share.team.url}/share/${share.id}`,
|
||||||
createdBy: presentUser(share.user),
|
createdBy: presentUser(share.user),
|
||||||
|
includeChildDocuments: share.includeChildDocuments,
|
||||||
lastAccessedAt: share.lastAccessedAt,
|
lastAccessedAt: share.lastAccessedAt,
|
||||||
createdAt: share.createdAt,
|
createdAt: share.createdAt,
|
||||||
updatedAt: share.updatedAt,
|
updatedAt: share.updatedAt,
|
||||||
|
@ -3,17 +3,17 @@
|
|||||||
"currently viewing": "currently viewing",
|
"currently viewing": "currently viewing",
|
||||||
"previously edited": "previously edited",
|
"previously edited": "previously edited",
|
||||||
"You": "You",
|
"You": "You",
|
||||||
"Trash": "Trash",
|
|
||||||
"Archive": "Archive",
|
|
||||||
"Drafts": "Drafts",
|
|
||||||
"Templates": "Templates",
|
|
||||||
"Deleted Collection": "Deleted Collection",
|
|
||||||
"Viewers": "Viewers",
|
"Viewers": "Viewers",
|
||||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||||
"Add a description": "Add a description",
|
"Add a description": "Add a description",
|
||||||
"Collapse": "Collapse",
|
"Collapse": "Collapse",
|
||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Submenu": "Submenu",
|
"Submenu": "Submenu",
|
||||||
|
"Trash": "Trash",
|
||||||
|
"Archive": "Archive",
|
||||||
|
"Drafts": "Drafts",
|
||||||
|
"Templates": "Templates",
|
||||||
|
"Deleted Collection": "Deleted Collection",
|
||||||
"New": "New",
|
"New": "New",
|
||||||
"Only visible to you": "Only visible to you",
|
"Only visible to you": "Only visible to you",
|
||||||
"Draft": "Draft",
|
"Draft": "Draft",
|
||||||
@ -290,13 +290,18 @@
|
|||||||
"New from template": "New from template",
|
"New from template": "New from template",
|
||||||
"Publish": "Publish",
|
"Publish": "Publish",
|
||||||
"Publishing": "Publishing",
|
"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",
|
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
|
||||||
"Share": "Share",
|
"Share": "Share",
|
||||||
"Share this document": "Share this document",
|
"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",
|
"Publish to internet": "Publish to internet",
|
||||||
"Anyone with the link can view this document": "Anyone with the link can view this document",
|
"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 }}.",
|
"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 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.",
|
"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 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.",
|
"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.",
|
||||||
|
Reference in New Issue
Block a user