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
|
||||
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" />
|
||||
|
||||
<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;
|
||||
}
|
||||
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 />
|
||||
|
||||
<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;
|
||||
|
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 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>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -10,6 +10,7 @@ class Share extends BaseModel {
|
||||
documentTitle: string;
|
||||
documentUrl: string;
|
||||
lastAccessedAt: ?string;
|
||||
includeChildDocuments: boolean;
|
||||
createdBy: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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} />
|
||||
|
@ -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}
|
||||
|
@ -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={
|
||||
<>
|
||||
|
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
|
||||
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);
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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({
|
||||
|
@ -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({
|
||||
|
@ -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({
|
||||
|
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();
|
||||
};
|
||||
|
||||
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
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 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