feat: Nested document sharing (#2075)

* migration

* frontend routing, api permissioning

* feat: apiVersion=2

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

* poc nested documents on share links

* fix: nested shareId permissions

* ui and language tweaks, comments

* breadcrumbs

* Add icons to reference list items

* refactor: Breadcrumb component

* tweaks

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

View File

@@ -22,11 +22,10 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState } from "types";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
location: LocationWithState,
@@ -41,6 +40,7 @@ type Props = {|
@observer
class DataLoader extends React.Component<Props> {
@observable sharedTree: ?NavigationNode;
@observable document: ?Document;
@observable revision: ?Revision;
@observable error: ?Error;
@@ -89,7 +89,7 @@ class DataLoader extends React.Component<Props> {
// search for exact internal document
const slug = parseDocumentSlug(term);
try {
const document = await this.props.documents.fetch(slug);
const { document } = await this.props.documents.fetch(slug);
const time = distanceInWordsToNow(document.updatedAt, {
addSuffix: true,
});
@@ -159,10 +159,13 @@ class DataLoader extends React.Component<Props> {
}
try {
this.document = await this.props.documents.fetch(documentSlug, {
const response = await this.props.documents.fetch(documentSlug, {
shareId,
});
this.document = response.document;
this.sharedTree = response.sharedTree;
if (revisionId && revisionId !== "latest") {
await this.loadRevision();
} else {
@@ -249,6 +252,7 @@ class DataLoader extends React.Component<Props> {
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
);

View File

@@ -29,8 +29,9 @@ import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
import PublicReferences from "./PublicReferences";
import References from "./References";
import { type LocationWithState, type Theme } from "types";
import { type LocationWithState, type NavigationNode, type Theme } from "types";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
import { meta } from "utils/keyboard";
@@ -57,6 +58,7 @@ type Props = {
match: Match,
history: RouterHistory,
location: LocationWithState,
sharedTree: ?NavigationNode,
abilities: Object,
document: Document,
revision: Revision,
@@ -311,7 +313,8 @@ class DocumentScene extends React.Component<Props> {
match,
} = this.props;
const team = auth.team;
const isShare = !!match.params.shareId;
const { shareId } = match.params;
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
@@ -367,7 +370,7 @@ class DocumentScene extends React.Component<Props> {
)}
<Header
document={document}
isShare={isShare}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly}
@@ -377,6 +380,7 @@ class DocumentScene extends React.Component<Props> {
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSave={this.onSave}
/>
@@ -420,7 +424,7 @@ class DocumentScene extends React.Component<Props> {
<Editor
id={document.id}
innerRef={this.editor}
isShare={isShare}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
@@ -442,6 +446,15 @@ class DocumentScene extends React.Component<Props> {
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />

View File

@@ -24,7 +24,7 @@ type Props = {|
title: string,
document: Document,
isDraft: boolean,
isShare: boolean,
shareId: ?string,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
@@ -97,7 +97,7 @@ class DocumentEditor extends React.Component<Props> {
title,
onChangeTitle,
isDraft,
isShare,
shareId,
readOnly,
innerRef,
children,
@@ -118,7 +118,7 @@ class DocumentEditor extends React.Component<Props> {
$isStarred={document.isStarred}
>
<span>{normalizedTitle}</span>{" "}
{!isShare && <StarButton document={document} size={32} />}
{!shareId && <StarButton document={document} size={32} />}
</Title>
) : (
<Title
@@ -144,11 +144,12 @@ class DocumentEditor extends React.Component<Props> {
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
shareId={shareId}
grow
{...rest}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent && !isShare && readOnly && (
{this.activeLinkEvent && !shareId && readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
event={this.activeLinkEvent}

View File

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

View File

@@ -0,0 +1,54 @@
// @flow
import * as React from "react";
import Breadcrumb from "components/Breadcrumb";
import type { NavigationNode } from "types";
type Props = {|
documentId: string,
shareId: string,
sharedTree: ?NavigationNode,
children?: React.Node,
|};
function pathToDocument(sharedTree, documentId) {
let path = [];
const traveler = (nodes, previousPath) => {
nodes.forEach((childNode) => {
const newPath = [...previousPath, childNode];
if (childNode.id === documentId) {
path = newPath;
return;
}
return traveler(childNode.children, newPath);
});
};
if (sharedTree) {
traveler([sharedTree], []);
}
return path;
}
const PublicBreadcrumb = ({
documentId,
shareId,
sharedTree,
children,
}: Props) => {
const items = React.useMemo(
() =>
pathToDocument(sharedTree, documentId)
.slice(0, -1)
.map((item) => {
return {
...item,
to: `/share/${shareId}${item.url}`,
};
}),
[sharedTree, shareId, documentId]
);
return <Breadcrumb items={items} children={children} />;
};
export default PublicBreadcrumb;

View File

@@ -0,0 +1,57 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Subheading from "components/Subheading";
import ReferenceListItem from "./ReferenceListItem";
import { type NavigationNode } from "types";
type Props = {|
shareId: string,
documentId: string,
sharedTree: NavigationNode,
|};
function PublicReferences(props: Props) {
const { t } = useTranslation();
const { shareId, documentId, sharedTree } = props;
// The sharedTree is the entire document tree starting at the shared document
// we must filter down the tree to only the part with the document we're
// currently viewing
const children = React.useMemo(() => {
let result;
function findChildren(node) {
if (!node) return;
if (node.id === documentId) {
result = node.children;
} else {
node.children.forEach((node) => {
if (result) {
return;
}
findChildren(node);
});
}
return result;
}
return findChildren(sharedTree) || [];
}, [documentId, sharedTree]);
if (!children.length) {
return null;
}
return (
<>
<Subheading>{t("Nested documents")}</Subheading>
{children.map((node) => (
<ReferenceListItem key={node.id} document={node} shareId={shareId} />
))}
</>
);
}
export default observer(PublicReferences);

View File

@@ -1,5 +1,6 @@
// @flow
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import DocumentMeta from "components/DocumentMeta";
import type { NavigationNode } from "types";
type Props = {|
shareId?: string,
document: Document | NavigationNode,
anchor?: string,
showCollection?: boolean,
@@ -31,6 +33,8 @@ const DocumentLink = styled(Link)`
`;
const Title = styled.h3`
display: flex;
align-items: center;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
@@ -43,27 +47,52 @@ const Title = styled.h3`
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
@observer
class ReferenceListItem extends React.Component<Props> {
render() {
const { document, showCollection, anchor, ...rest } = this.props;
const StyledDocumentIcon = styled(DocumentIcon)`
margin-left: -4px;
color: ${(props) => props.theme.textSecondary};
`;
return (
<DocumentLink
to={{
pathname: document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: { title: document.title },
}}
{...rest}
>
<Title>{document.title}</Title>
{document.updatedBy && (
<DocumentMeta document={document} showCollection={showCollection} />
)}
</DocumentLink>
);
}
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: 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);

View File

@@ -19,7 +19,9 @@ function ShareButton({ document }: Props) {
const { t } = useTranslation();
const { shares } = useStores();
const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published;
const sharedParent = shares.getByDocumentParents(document.id);
const isPubliclyShared =
(share && share.published) || (sharedParent && sharedParent.published);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
@@ -57,6 +59,7 @@ function ShareButton({ document }: Props) {
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onSubmit={popover.hide}
/>
</Popover>

View File

@@ -4,7 +4,7 @@ import invariant from "invariant";
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Share from "models/Share";
@@ -13,23 +13,25 @@ import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
share: Share,
sharedParent: ?Share,
onSubmit: () => void,
|};
function DocumentShare({ document, share, onSubmit }: Props) {
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation();
const { policies, shares, ui } = useStores();
const [isCopied, setIsCopied] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const timeout = React.useRef<?TimeoutID>();
const can = policies.abilities(share ? share.id : "");
const canPublish = can.update && !document.isTemplate;
const isPubliclyShared = (share && share.published) || sharedParent;
React.useEffect(() => {
document.share();
@@ -41,14 +43,26 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
setIsSaving(true);
try {
await share.save({ published: event.currentTarget.checked });
} catch (err) {
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[document.id, shares, ui]
);
const handleChildDocumentsChange = React.useCallback(
async (event) => {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
try {
await share.save({
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, ui]
@@ -68,7 +82,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
return (
<>
<Heading>
{share && share.published ? (
{isPubliclyShared ? (
<GlobeIcon size={28} color="currentColor" />
) : (
<PadlockIcon size={28} color="currentColor" />
@@ -76,20 +90,30 @@ function DocumentShare({ document, share, onSubmit }: Props) {
{t("Share this document")}
</Heading>
{sharedParent && (
<Notice>
<Trans
defaults="This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared"
values={{ documentTitle: sharedParent.documentTitle }}
components={{ em: <strong /> }}
/>
</Notice>
)}
{canPublish && (
<PrivacySwitch>
<SwitchWrapper>
<Switch
id="published"
label={t("Publish to internet")}
onChange={handlePublishedChange}
checked={share ? share.published : false}
disabled={!share || isSaving}
disabled={!share}
/>
<Privacy>
<PrivacyText>
<SwitchLabel>
<SwitchText>
{share.published
? t("Anyone with the link can view this document")
: t("Only team members with access can view")}
: t("Only team members with permission can view")}
{share.lastAccessedAt && (
<>
.{" "}
@@ -100,9 +124,27 @@ function DocumentShare({ document, share, onSubmit }: Props) {
})}
</>
)}
</PrivacyText>
</Privacy>
</PrivacySwitch>
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
{share && share.published && (
<SwitchWrapper>
<Switch
id="includeChildDocuments"
label={t("Share nested documents")}
onChange={handleChildDocumentsChange}
checked={share ? share.includeChildDocuments : false}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{share.includeChildDocuments
? t("Nested documents are publicly available")
: t("Nested documents are not shared")}
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
<Flex>
<InputLink
@@ -130,7 +172,7 @@ const Heading = styled.h2`
margin-left: -4px;
`;
const PrivacySwitch = styled.div`
const SwitchWrapper = styled.div`
margin: 20px 0;
`;
@@ -139,7 +181,7 @@ const InputLink = styled(Input)`
margin-right: 8px;
`;
const Privacy = styled(Flex)`
const SwitchLabel = styled(Flex)`
flex-align: center;
svg {
@@ -147,9 +189,9 @@ const Privacy = styled(Flex)`
}
`;
const PrivacyText = styled(HelpText)`
const SwitchText = styled(HelpText)`
margin: 0;
font-size: 15px;
`;
export default observer(DocumentShare);
export default observer(SharePopover);