feat: Nested document sharing (#2075)

* migration

* frontend routing, api permissioning

* feat: apiVersion=2

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

* poc nested documents on share links

* fix: nested shareId permissions

* ui and language tweaks, comments

* breadcrumbs

* Add icons to reference list items

* refactor: Breadcrumb component

* tweaks

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

View File

@@ -1,193 +1,87 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import { GoToIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
function Icon({ document }) {
const { t } = useTranslation();
type Props = {|
items: MenuItem[],
max?: number,
children?: React.Node,
highlightFirstItem?: boolean,
|};
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
let topLevelItems: MenuItem[] = [...items];
let overflowItems;
const Breadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
if (!collections.isLoaded) {
return;
// chop middle breadcrumbs and present a "..." menu instead
if (totalItems > max) {
const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
topLevelItems.splice(halfMax, 0, {
title: <BreadcrumbMenu items={overflowItems} />,
});
}
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Flex justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
)}
{topLevelItems.map((item, index) => (
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
</React.Fragment>
))}
{children}
</Flex>
);
};
}
export const Slash = styled(GoToIcon)`
const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
const Crumb = styled(Link)`
const Item = styled(Link)`
display: flex;
flex-shrink: 1;
min-width: 0;
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&:hover {
text-decoration: underline;
}
`;
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);
export default Breadcrumb;

View File

@@ -0,0 +1,137 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
|};
function useCategory(document) {
const { t } = useTranslation();
if (document.isDeleted) {
return {
icon: <TrashIcon color="currentColor" />,
title: t("Trash"),
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
title: t("Archive"),
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
title: t("Drafts"),
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
title: t("Templates"),
to: "/templates",
};
}
return null;
}
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = React.useMemo(
() =>
collection && collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [],
[collection, document.id]
);
const items = React.useMemo(() => {
let output = [];
if (category) {
output.push(category);
}
if (collection) {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.id),
});
}
path.forEach((p) => {
output.push({
title: p.title,
to: p.url,
});
});
return output;
}, [path, category, collection]);
if (!collections.isLoaded) {
return;
}
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
};
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";
@@ -142,7 +142,7 @@ function DocumentMeta({
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
<DocumentBreadcrumb document={document} onlyText />
</strong>
</span>
)}

View File

@@ -27,6 +27,7 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
@@ -55,7 +56,7 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, ui, history } = props;
const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const isPrinting = useMediaQuery("print");
@@ -89,12 +90,16 @@ function Editor(props: PropsWithRef) {
}
}
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history]
[history, shareId]
);
const onShowToast = React.useCallback(

View File

@@ -144,9 +144,10 @@ class SocketProvider extends React.Component<Props> {
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
const response = await documents.fetch(documentId, {
force: true,
});
document = response.document;
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);

View File

@@ -36,7 +36,7 @@ export default function useImportDocument(
const redirect = files.length === 1;
if (documentId && !collectionId) {
const document = await documents.fetch(documentId);
const { document } = await documents.fetch(documentId);
invariant(document, "Document not available");
cId = document.collectionId;
}

View File

@@ -6,11 +6,17 @@ import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
type Props = {
path: Array<any>,
items: MenuItem[],
};
export default function BreadcrumbMenu({ path }: Props) {
export default function BreadcrumbMenu({ items }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
@@ -21,13 +27,7 @@ export default function BreadcrumbMenu({ path }: Props) {
<>
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);

View File

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

View File

@@ -4,6 +4,7 @@ import { Switch } from "react-router-dom";
import DelayedMount from "components/DelayedMount";
import FullscreenLoading from "components/FullscreenLoading";
import Route from "components/ProfiledRoute";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
@@ -25,6 +26,11 @@ export default function Routes() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
</Authenticated>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,12 @@ import BaseStore from "stores/BaseStore";
import RootStore from "stores/RootStore";
import Document from "models/Document";
import env from "env";
import type { FetchOptions, PaginationParams, SearchResult } from "types";
import type {
NavigationNode,
FetchOptions,
PaginationParams,
SearchResult,
} from "types";
import { client } from "utils/ApiClient";
type ImportOptions = {
@@ -447,30 +452,30 @@ export default class DocumentsStore extends BaseStore<Document> {
fetch = async (
id: string,
options: FetchOptions = {}
): Promise<?Document> => {
): Promise<{ document: ?Document, sharedTree?: NavigationNode }> => {
if (!options.prefetch) this.isFetching = true;
try {
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
if (doc && policy && !options.force) {
return doc;
return { document: doc };
}
const res = await client.post("/documents.info", {
id,
shareId: options.shareId,
apiVersion: 2,
});
invariant(res && res.data, "Document not available");
this.addPolicies(res.policies);
this.add(res.data);
this.add(res.data.document);
runInAction("DocumentsStore#fetch", () => {
this.isLoaded = true;
});
return this.data.get(res.data.id);
return {
document: this.data.get(res.data.document.id),
sharedTree: res.data.sharedTree,
};
} finally {
this.isFetching = false;
}

View File

@@ -46,19 +46,42 @@ export default class SharesStore extends BaseStore<Share> {
this.isFetching = true;
try {
const res = await client.post(`/${this.modelName}s.info`, { documentId });
const res = await client.post(`/${this.modelName}s.info`, {
documentId,
apiVersion: 2,
});
if (isUndefined(res)) return;
invariant(res && res.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(res.data);
return res.data.shares.map(this.add);
} finally {
this.isFetching = false;
}
}
getByDocumentId = (documentId): ?Share => {
getByDocumentParents = (documentId: string): ?Share => {
const document = this.rootStore.documents.get(documentId);
if (!document) return;
const collection = this.rootStore.collections.get(document.collectionId);
if (!collection) return;
const parentIds = collection
.pathToDocument(documentId)
.slice(0, -1)
.map((p) => p.id);
for (const parentId of parentIds) {
const share = this.getByDocumentId(parentId);
if (share && share.includeChildDocuments && share.published) {
return share;
}
}
};
getByDocumentId = (documentId: string): ?Share => {
return find(this.orderedData, (share) => share.documentId === documentId);
};
}