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};
|
||||
`;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user