feat: Add navigation sidebar to shared documents (#2899)
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
100
app/components/AuthenticatedLayout.tsx
Normal file
100
app/components/AuthenticatedLayout.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { withTranslation, WithTranslation } from "react-i18next";
|
||||||
|
import { Switch, Route } from "react-router-dom";
|
||||||
|
import RootStore from "~/stores/RootStore";
|
||||||
|
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||||
|
import Layout from "~/components/Layout";
|
||||||
|
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||||
|
import Sidebar from "~/components/Sidebar";
|
||||||
|
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||||
|
import history from "~/utils/history";
|
||||||
|
import {
|
||||||
|
searchUrl,
|
||||||
|
matchDocumentSlug as slug,
|
||||||
|
newDocumentPath,
|
||||||
|
settingsPath,
|
||||||
|
} from "~/utils/routeHelpers";
|
||||||
|
import withStores from "./withStores";
|
||||||
|
|
||||||
|
const DocumentHistory = React.lazy(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "document-history" */
|
||||||
|
"~/components/DocumentHistory"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const CommandBar = React.lazy(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "command-bar" */
|
||||||
|
"~/components/CommandBar"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = WithTranslation &
|
||||||
|
RootStore & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class AuthenticatedLayout extends React.Component<Props> {
|
||||||
|
scrollable: HTMLDivElement | null | undefined;
|
||||||
|
|
||||||
|
@observable
|
||||||
|
keyboardShortcutsOpen = false;
|
||||||
|
|
||||||
|
goToSearch = (ev: KeyboardEvent) => {
|
||||||
|
if (!ev.metaKey && !ev.ctrlKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
history.push(searchUrl());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
goToNewDocument = () => {
|
||||||
|
const { activeCollectionId } = this.props.ui;
|
||||||
|
if (!activeCollectionId) return;
|
||||||
|
const can = this.props.policies.abilities(activeCollectionId);
|
||||||
|
if (!can.update) return;
|
||||||
|
history.push(newDocumentPath(activeCollectionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { auth } = this.props;
|
||||||
|
const { user, team } = auth;
|
||||||
|
const showSidebar = auth.authenticated && user && team;
|
||||||
|
if (auth.isSuspended) return <ErrorSuspended />;
|
||||||
|
|
||||||
|
const sidebar = showSidebar ? (
|
||||||
|
<Switch>
|
||||||
|
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||||
|
<Route component={Sidebar} />
|
||||||
|
</Switch>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
const rightRail = (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={`/doc/${slug}/history/:revisionId?`}
|
||||||
|
component={DocumentHistory}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
|
||||||
|
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
|
||||||
|
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
|
||||||
|
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
|
||||||
|
{this.props.children}
|
||||||
|
<CommandBar />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withTranslation()(withStores(AuthenticatedLayout));
|
||||||
@@ -17,6 +17,7 @@ function Branding({ href = env.URL }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Link = styled.a`
|
const Link = styled.a`
|
||||||
|
z-index: ${(props) => props.theme.depths.sidebar + 1};
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import styled from "styled-components";
|
|||||||
const RealButton = styled.button<{
|
const RealButton = styled.button<{
|
||||||
fullwidth?: boolean;
|
fullwidth?: boolean;
|
||||||
borderOnHover?: boolean;
|
borderOnHover?: boolean;
|
||||||
neutral?: boolean;
|
$neutral?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
}>`
|
}>`
|
||||||
@@ -55,7 +55,7 @@ const RealButton = styled.button<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.neutral &&
|
props.$neutral &&
|
||||||
`
|
`
|
||||||
background: ${props.theme.buttonNeutralBackground};
|
background: ${props.theme.buttonNeutralBackground};
|
||||||
color: ${props.theme.buttonNeutralText};
|
color: ${props.theme.buttonNeutralText};
|
||||||
@@ -158,7 +158,7 @@ const Button = <T extends React.ElementType = "button">(
|
|||||||
const hasIcon = icon !== undefined;
|
const hasIcon = icon !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RealButton type={type || "button"} ref={ref} neutral={neutral} {...rest}>
|
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
|
||||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||||
{hasIcon && icon}
|
{hasIcon && icon}
|
||||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||||
|
|||||||
@@ -1,151 +1,80 @@
|
|||||||
import { observable } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { MenuIcon } from "outline-icons";
|
import { MenuIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { withTranslation, WithTranslation } from "react-i18next";
|
|
||||||
import { Switch, Route } from "react-router-dom";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import RootStore from "~/stores/RootStore";
|
|
||||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
|
||||||
import Sidebar from "~/components/Sidebar";
|
|
||||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
|
||||||
import SkipNavContent from "~/components/SkipNavContent";
|
import SkipNavContent from "~/components/SkipNavContent";
|
||||||
import SkipNavLink from "~/components/SkipNavLink";
|
import SkipNavLink from "~/components/SkipNavLink";
|
||||||
import history from "~/utils/history";
|
import useKeyDown from "~/hooks/useKeyDown";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
import { isModKey } from "~/utils/keyboard";
|
import { isModKey } from "~/utils/keyboard";
|
||||||
import {
|
|
||||||
searchUrl,
|
|
||||||
matchDocumentSlug as slug,
|
|
||||||
newDocumentPath,
|
|
||||||
settingsPath,
|
|
||||||
} from "~/utils/routeHelpers";
|
|
||||||
import withStores from "./withStores";
|
|
||||||
|
|
||||||
const DocumentHistory = React.lazy(
|
type Props = {
|
||||||
() =>
|
title?: string;
|
||||||
import(
|
children?: React.ReactNode;
|
||||||
/* webpackChunkName: "document-history" */
|
sidebar?: React.ReactNode;
|
||||||
"~/components/DocumentHistory"
|
rightRail?: React.ReactNode;
|
||||||
)
|
};
|
||||||
);
|
|
||||||
const CommandBar = React.lazy(
|
|
||||||
() =>
|
|
||||||
import(
|
|
||||||
/* webpackChunkName: "command-bar" */
|
|
||||||
"~/components/CommandBar"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
type Props = WithTranslation &
|
function Layout({ title, children, sidebar, rightRail }: Props) {
|
||||||
RootStore & {
|
const { ui } = useStores();
|
||||||
children?: React.ReactNode;
|
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
|
||||||
};
|
|
||||||
|
|
||||||
@observer
|
useKeyDown(".", (event) => {
|
||||||
class Layout extends React.Component<Props> {
|
if (isModKey(event)) {
|
||||||
scrollable: HTMLDivElement | null | undefined;
|
ui.toggleCollapsedSidebar();
|
||||||
|
|
||||||
@observable
|
|
||||||
keyboardShortcutsOpen = false;
|
|
||||||
|
|
||||||
goToSearch = (ev: KeyboardEvent) => {
|
|
||||||
if (!ev.metaKey && !ev.ctrlKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
history.push(searchUrl());
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
goToNewDocument = () => {
|
return (
|
||||||
const { activeCollectionId } = this.props.ui;
|
<Container column auto>
|
||||||
if (!activeCollectionId) return;
|
<Helmet>
|
||||||
const can = this.props.policies.abilities(activeCollectionId);
|
<title>{title ? title : "Outline"}</title>
|
||||||
if (!can.update) return;
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
history.push(newDocumentPath(activeCollectionId));
|
</Helmet>
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
<SkipNavLink />
|
||||||
const { auth, ui } = this.props;
|
|
||||||
const { user, team } = auth;
|
|
||||||
const showSidebar = auth.authenticated && user && team;
|
|
||||||
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
|
|
||||||
if (auth.isSuspended) return <ErrorSuspended />;
|
|
||||||
|
|
||||||
return (
|
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||||
<Container column auto>
|
|
||||||
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
|
|
||||||
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
|
|
||||||
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
|
|
||||||
<RegisterKeyDown
|
|
||||||
trigger="."
|
|
||||||
handler={(event) => {
|
|
||||||
if (isModKey(event)) {
|
|
||||||
ui.toggleCollapsedSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Helmet>
|
|
||||||
<title>{team && team.name ? team.name : "Outline"}</title>
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0"
|
|
||||||
/>
|
|
||||||
</Helmet>
|
|
||||||
<SkipNavLink />
|
|
||||||
|
|
||||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
|
||||||
|
|
||||||
|
{sidebar && (
|
||||||
<MobileMenuButton
|
<MobileMenuButton
|
||||||
onClick={ui.toggleMobileSidebar}
|
onClick={ui.toggleMobileSidebar}
|
||||||
icon={<MenuIcon />}
|
icon={<MenuIcon />}
|
||||||
iconColor="currentColor"
|
iconColor="currentColor"
|
||||||
neutral
|
neutral
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Container auto>
|
<Container auto>
|
||||||
{showSidebar && (
|
{sidebar}
|
||||||
<Switch>
|
|
||||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
|
||||||
<Route component={Sidebar} />
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SkipNavContent />
|
<SkipNavContent />
|
||||||
<Content
|
<Content
|
||||||
auto
|
auto
|
||||||
justify="center"
|
justify="center"
|
||||||
$isResizing={ui.sidebarIsResizing}
|
$isResizing={ui.sidebarIsResizing}
|
||||||
$sidebarCollapsed={sidebarCollapsed}
|
$sidebarCollapsed={sidebarCollapsed}
|
||||||
style={
|
$hasSidebar={!!sidebar}
|
||||||
sidebarCollapsed
|
style={
|
||||||
? undefined
|
sidebarCollapsed
|
||||||
: {
|
? undefined
|
||||||
marginLeft: `${ui.sidebarWidth}px`,
|
: {
|
||||||
}
|
marginLeft: `${ui.sidebarWidth}px`,
|
||||||
}
|
}
|
||||||
>
|
}
|
||||||
{this.props.children}
|
>
|
||||||
</Content>
|
{children}
|
||||||
|
</Content>
|
||||||
|
|
||||||
<React.Suspense fallback={null}>
|
{rightRail}
|
||||||
<Switch>
|
|
||||||
<Route
|
|
||||||
path={`/doc/${slug}/history/:revisionId?`}
|
|
||||||
component={DocumentHistory}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</React.Suspense>
|
|
||||||
</Container>
|
|
||||||
<CommandBar />
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
</Container>
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled(Flex)`
|
const Container = styled(Flex)`
|
||||||
@@ -174,6 +103,7 @@ const MobileMenuButton = styled(Button)`
|
|||||||
const Content = styled(Flex)<{
|
const Content = styled(Flex)<{
|
||||||
$isResizing?: boolean;
|
$isResizing?: boolean;
|
||||||
$sidebarCollapsed?: boolean;
|
$sidebarCollapsed?: boolean;
|
||||||
|
$hasSidebar?: boolean;
|
||||||
}>`
|
}>`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
transition: ${(props) =>
|
transition: ${(props) =>
|
||||||
@@ -189,9 +119,10 @@ const Content = styled(Flex)<{
|
|||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
${(props: any) =>
|
${(props: any) =>
|
||||||
|
props.$hasSidebar &&
|
||||||
props.$sidebarCollapsed &&
|
props.$sidebarCollapsed &&
|
||||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withTranslation()(withStores(Layout));
|
export default observer(Layout);
|
||||||
|
|||||||
35
app/components/Sidebar/Shared.tsx
Normal file
35
app/components/Sidebar/Shared.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import Scrollable from "~/components/Scrollable";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { NavigationNode } from "~/types";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
import Section from "./components/Section";
|
||||||
|
import DocumentLink from "./components/SharedDocumentLink";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rootNode: NavigationNode;
|
||||||
|
shareId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SharedSidebar({ rootNode, shareId }: Props) {
|
||||||
|
const { documents } = useStores();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar>
|
||||||
|
<Scrollable flex>
|
||||||
|
<Section>
|
||||||
|
<DocumentLink
|
||||||
|
index={0}
|
||||||
|
shareId={shareId}
|
||||||
|
depth={1}
|
||||||
|
node={rootNode}
|
||||||
|
activeDocument={documents.active}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Scrollable>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(SharedSidebar);
|
||||||
@@ -78,6 +78,7 @@ const NavLink = ({
|
|||||||
strict,
|
strict,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isActive = !!(isActiveProp
|
const isActive = !!(isActiveProp
|
||||||
? isActiveProp(match, currentLocation)
|
? isActiveProp(match, currentLocation)
|
||||||
: match);
|
: match);
|
||||||
|
|||||||
128
app/components/Sidebar/components/SharedDocumentLink.tsx
Normal file
128
app/components/Sidebar/components/SharedDocumentLink.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
|
import Document from "~/models/Document";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { NavigationNode } from "~/types";
|
||||||
|
import Disclosure from "./Disclosure";
|
||||||
|
import SidebarLink from "./SidebarLink";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
node: NavigationNode;
|
||||||
|
collection?: Collection;
|
||||||
|
activeDocument: Document | null | undefined;
|
||||||
|
isDraft?: boolean;
|
||||||
|
depth: number;
|
||||||
|
index: number;
|
||||||
|
shareId: string;
|
||||||
|
parentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocumentLink(
|
||||||
|
{ node, collection, activeDocument, isDraft, depth, shareId }: Props,
|
||||||
|
ref: React.RefObject<HTMLAnchorElement>
|
||||||
|
) {
|
||||||
|
const { documents } = useStores();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||||
|
|
||||||
|
const hasChildDocuments =
|
||||||
|
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||||
|
const document = documents.get(node.id);
|
||||||
|
|
||||||
|
const showChildren = React.useMemo(() => {
|
||||||
|
return !!hasChildDocuments;
|
||||||
|
}, [hasChildDocuments]);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = React.useState(showChildren);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showChildren) {
|
||||||
|
setExpanded(showChildren);
|
||||||
|
}
|
||||||
|
}, [showChildren]);
|
||||||
|
|
||||||
|
const handleDisclosureClick = React.useCallback(
|
||||||
|
(ev: React.SyntheticEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
setExpanded(!expanded);
|
||||||
|
},
|
||||||
|
[expanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
// since we don't have access to the collection sort here, we just put any
|
||||||
|
// drafts at the front of the list. this is slightly inconsistent with the
|
||||||
|
// logged-in behavior, but it's probably better to emphasize the draft state
|
||||||
|
// of the document in a shared context
|
||||||
|
const nodeChildren = React.useMemo(() => {
|
||||||
|
if (
|
||||||
|
activeDocument?.isDraft &&
|
||||||
|
activeDocument?.isActive &&
|
||||||
|
activeDocument?.parentDocumentId === node.id
|
||||||
|
) {
|
||||||
|
return [activeDocument?.asNavigationNode, ...node.children];
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.children;
|
||||||
|
}, [
|
||||||
|
activeDocument?.isActive,
|
||||||
|
activeDocument?.isDraft,
|
||||||
|
activeDocument?.parentDocumentId,
|
||||||
|
activeDocument?.asNavigationNode,
|
||||||
|
node,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||||
|
t("Untitled");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarLink
|
||||||
|
to={{
|
||||||
|
pathname: `/share/${shareId}${node.url}`,
|
||||||
|
state: {
|
||||||
|
title: node.title,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
{hasChildDocuments && (
|
||||||
|
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
depth={depth}
|
||||||
|
exact={false}
|
||||||
|
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||||
|
isDraft={isDraft}
|
||||||
|
ref={ref}
|
||||||
|
isActive={() => {
|
||||||
|
return !!isActiveDocument;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{expanded &&
|
||||||
|
nodeChildren.map((childNode, index) => (
|
||||||
|
<ObservedDocumentLink
|
||||||
|
shareId={shareId}
|
||||||
|
key={childNode.id}
|
||||||
|
collection={collection}
|
||||||
|
node={childNode}
|
||||||
|
activeDocument={activeDocument}
|
||||||
|
isDraft={childNode.isDraft}
|
||||||
|
depth={depth + 1}
|
||||||
|
index={index}
|
||||||
|
parentId={node.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
|
||||||
|
|
||||||
|
export default ObservedDocumentLink;
|
||||||
@@ -8,8 +8,8 @@ import Error404 from "~/scenes/Error404";
|
|||||||
import Search from "~/scenes/Search";
|
import Search from "~/scenes/Search";
|
||||||
import Templates from "~/scenes/Templates";
|
import Templates from "~/scenes/Templates";
|
||||||
import Trash from "~/scenes/Trash";
|
import Trash from "~/scenes/Trash";
|
||||||
|
import Layout from "~/components/AuthenticatedLayout";
|
||||||
import CenteredContent from "~/components/CenteredContent";
|
import CenteredContent from "~/components/CenteredContent";
|
||||||
import Layout from "~/components/Layout";
|
|
||||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||||
import Route from "~/components/ProfiledRoute";
|
import Route from "~/components/ProfiledRoute";
|
||||||
import SocketProvider from "~/components/SocketProvider";
|
import SocketProvider from "~/components/SocketProvider";
|
||||||
|
|||||||
@@ -54,12 +54,14 @@ export default function Routes() {
|
|||||||
<Route exact path="/" component={Login} />
|
<Route exact path="/" component={Login} />
|
||||||
<Route exact path="/create" component={Login} />
|
<Route exact path="/create" component={Login} />
|
||||||
<Route exact path="/logout" component={Logout} />
|
<Route exact path="/logout" component={Logout} />
|
||||||
|
|
||||||
<Route exact path="/share/:shareId" component={SharedDocument} />
|
<Route exact path="/share/:shareId" component={SharedDocument} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={`/share/:shareId/doc/${slug}`}
|
path={`/share/:shareId/doc/${slug}`}
|
||||||
component={SharedDocument}
|
component={SharedDocument}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Authenticated>
|
<Authenticated>
|
||||||
<AuthenticatedRoutes />
|
<AuthenticatedRoutes />
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Location } from "history";
|
import { Location } from "history";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { RouteComponentProps } from "react-router-dom";
|
import { RouteComponentProps } from "react-router-dom";
|
||||||
import { useTheme } from "styled-components";
|
import { useTheme } from "styled-components";
|
||||||
import DocumentModel from "~/models/Document";
|
import DocumentModel from "~/models/Document";
|
||||||
import Error404 from "~/scenes/Error404";
|
import Error404 from "~/scenes/Error404";
|
||||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||||
|
import Layout from "~/components/Layout";
|
||||||
|
import Sidebar from "~/components/Sidebar/Shared";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import { OfflineError } from "~/utils/errors";
|
import { OfflineError } from "~/utils/errors";
|
||||||
@@ -20,7 +23,9 @@ type Props = RouteComponentProps<{
|
|||||||
location: Location<{ title?: string }>;
|
location: Location<{ title?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SharedDocumentScene(props: Props) {
|
function SharedDocumentScene(props: Props) {
|
||||||
|
const { ui } = useStores();
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [response, setResponse] = React.useState<{
|
const [response, setResponse] = React.useState<{
|
||||||
document: DocumentModel;
|
document: DocumentModel;
|
||||||
@@ -42,13 +47,13 @@ export default function SharedDocumentScene(props: Props) {
|
|||||||
shareId,
|
shareId,
|
||||||
});
|
});
|
||||||
setResponse(response);
|
setResponse(response);
|
||||||
|
ui.setActiveDocument(response.document);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [documents, documentSlug, shareId]);
|
}, [documents, documentSlug, shareId, ui]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||||
@@ -58,13 +63,21 @@ export default function SharedDocumentScene(props: Props) {
|
|||||||
return <Loading location={props.location} />;
|
return <Loading location={props.location} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sidebar = response.sharedTree ? (
|
||||||
|
<Sidebar rootNode={response.sharedTree} shareId={shareId} />
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document
|
<Layout title={response.document.title} sidebar={sidebar}>
|
||||||
abilities={EMPTY_OBJECT}
|
<Document
|
||||||
document={response.document}
|
abilities={EMPTY_OBJECT}
|
||||||
sharedTree={response.sharedTree}
|
document={response.document}
|
||||||
shareId={shareId}
|
sharedTree={response.sharedTree}
|
||||||
readOnly
|
shareId={shareId}
|
||||||
/>
|
readOnly
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(SharedDocumentScene);
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ type Props = RootStore &
|
|||||||
children: (arg0: any) => React.ReactNode;
|
children: (arg0: any) => React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sharedTreeCache = {};
|
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class DataLoader extends React.Component<Props> {
|
class DataLoader extends React.Component<Props> {
|
||||||
sharedTree: NavigationNode | null | undefined;
|
sharedTree: NavigationNode | null | undefined;
|
||||||
@@ -58,7 +56,7 @@ class DataLoader extends React.Component<Props> {
|
|||||||
const { documents, match } = this.props;
|
const { documents, match } = this.props;
|
||||||
this.document = documents.getByUrl(match.params.documentSlug);
|
this.document = documents.getByUrl(match.params.documentSlug);
|
||||||
this.sharedTree = this.document
|
this.sharedTree = this.document
|
||||||
? sharedTreeCache[this.document.id]
|
? documents.getSharedTree(this.document.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
this.loadDocument();
|
this.loadDocument();
|
||||||
}
|
}
|
||||||
@@ -192,7 +190,6 @@ class DataLoader extends React.Component<Props> {
|
|||||||
);
|
);
|
||||||
this.sharedTree = response.sharedTree;
|
this.sharedTree = response.sharedTree;
|
||||||
this.document = response.document;
|
this.document = response.document;
|
||||||
sharedTreeCache[this.document.id] = response.sharedTree;
|
|
||||||
|
|
||||||
if (revisionId && revisionId !== "latest") {
|
if (revisionId && revisionId !== "latest") {
|
||||||
await this.loadRevision();
|
await this.loadRevision();
|
||||||
|
|||||||
@@ -21,19 +21,19 @@ function References({ document }: Props) {
|
|||||||
documents.fetchBacklinks(document.id);
|
documents.fetchBacklinks(document.id);
|
||||||
}, [documents, document.id]);
|
}, [documents, document.id]);
|
||||||
|
|
||||||
const backlinks = documents.getBacklinedDocuments(document.id);
|
const backlinks = documents.getBacklinkedDocuments(document.id);
|
||||||
const collection = collections.get(document.collectionId);
|
const collection = collections.get(document.collectionId);
|
||||||
const children = collection
|
const children = collection
|
||||||
? collection.getDocumentChildren(document.id)
|
? collection.getDocumentChildren(document.id)
|
||||||
: [];
|
: [];
|
||||||
const showBacklinks = !!backlinks.length;
|
const showBacklinks = !!backlinks.length;
|
||||||
const showParentDocuments = !!children.length;
|
const showChildDocuments = !!children.length;
|
||||||
const isBacklinksTab = location.hash === "#backlinks" || !showParentDocuments;
|
const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments;
|
||||||
|
|
||||||
return showBacklinks || showParentDocuments ? (
|
return showBacklinks || showChildDocuments ? (
|
||||||
<Fade>
|
<Fade>
|
||||||
<Tabs>
|
<Tabs>
|
||||||
{showParentDocuments && (
|
{showChildDocuments && (
|
||||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||||
<Trans>Nested documents</Trans>
|
<Trans>Nested documents</Trans>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import RootStore from "~/stores/RootStore";
|
|||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import {
|
import {
|
||||||
NavigationNode,
|
|
||||||
FetchOptions,
|
FetchOptions,
|
||||||
PaginationParams,
|
PaginationParams,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
|
NavigationNode,
|
||||||
} from "~/types";
|
} from "~/types";
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ type ImportOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default class DocumentsStore extends BaseStore<Document> {
|
export default class DocumentsStore extends BaseStore<Document> {
|
||||||
|
sharedTreeCache: Map<string, NavigationNode | undefined> = new Map();
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
searchCache: Map<string, SearchResult[]> = new Map();
|
searchCache: Map<string, SearchResult[]> = new Map();
|
||||||
|
|
||||||
@@ -262,7 +264,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
getBacklinedDocuments(documentId: string): Document[] {
|
getBacklinkedDocuments(documentId: string): Document[] {
|
||||||
const documentIds = this.backlinks.get(documentId) || [];
|
const documentIds = this.backlinks.get(documentId) || [];
|
||||||
return orderBy(
|
return orderBy(
|
||||||
compact(documentIds.map((id) => this.data.get(id))),
|
compact(documentIds.map((id) => this.data.get(id))),
|
||||||
@@ -271,6 +273,10 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSharedTree(documentId: string): NavigationNode | undefined {
|
||||||
|
return this.sharedTreeCache.get(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
fetchChildDocuments = async (documentId: string): Promise<void> => {
|
fetchChildDocuments = async (documentId: string): Promise<void> => {
|
||||||
const res = await client.post(`/documents.list`, {
|
const res = await client.post(`/documents.list`, {
|
||||||
@@ -468,9 +474,16 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
||||||
|
|
||||||
if (doc && policy && !options.force) {
|
if (doc && policy && !options.force) {
|
||||||
return {
|
if (!options.shareId) {
|
||||||
document: doc,
|
return {
|
||||||
};
|
document: doc,
|
||||||
|
};
|
||||||
|
} else if (this.sharedTreeCache.has(options.shareId)) {
|
||||||
|
return {
|
||||||
|
document: doc,
|
||||||
|
sharedTree: this.sharedTreeCache.get(options.shareId),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await client.post("/documents.info", {
|
const res = await client.post("/documents.info", {
|
||||||
@@ -486,9 +499,16 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
const document = this.data.get(res.data.document.id);
|
const document = this.data.get(res.data.document.id);
|
||||||
invariant(document, "Document not available");
|
invariant(document, "Document not available");
|
||||||
|
|
||||||
|
if (options.shareId) {
|
||||||
|
this.sharedTreeCache.set(options.shareId, res.data.sharedTree);
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
sharedTree: res.data.sharedTree,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
sharedTree: res.data.sharedTree,
|
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ export type NavigationNode = {
|
|||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CollectionSort = {
|
||||||
|
field: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
// Pagination response in an API call
|
// Pagination response in an API call
|
||||||
export type Pagination = {
|
export type Pagination = {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe("getDocumentTree", () => {
|
|||||||
{ ...parent.toJSON(), children: [document.toJSON()] },
|
{ ...parent.toJSON(), children: [document.toJSON()] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(collection.getDocumentTree(parent.id)).toEqual({
|
expect(collection.getDocumentTree(parent.id)).toEqual({
|
||||||
...parent.toJSON(),
|
...parent.toJSON(),
|
||||||
children: [document.toJSON()],
|
children: [document.toJSON()],
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ import {
|
|||||||
DataType,
|
DataType,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
|
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||||
import { SLUG_URL_REGEX } from "@shared/utils/routeHelpers";
|
import { SLUG_URL_REGEX } from "@shared/utils/routeHelpers";
|
||||||
import slugify from "@server/utils/slugify";
|
import slugify from "@server/utils/slugify";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode, CollectionSort } from "~/types";
|
||||||
import CollectionGroup from "./CollectionGroup";
|
import CollectionGroup from "./CollectionGroup";
|
||||||
import CollectionUser from "./CollectionUser";
|
import CollectionUser from "./CollectionUser";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
@@ -39,6 +40,9 @@ import User from "./User";
|
|||||||
import ParanoidModel from "./base/ParanoidModel";
|
import ParanoidModel from "./base/ParanoidModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
|
// without this indirection, the app crashes on starup
|
||||||
|
type Sort = CollectionSort;
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
withAllMemberships: {
|
withAllMemberships: {
|
||||||
include: [
|
include: [
|
||||||
@@ -157,7 +161,7 @@ class Collection extends ParanoidModel {
|
|||||||
@Column({
|
@Column({
|
||||||
type: DataType.JSONB,
|
type: DataType.JSONB,
|
||||||
validate: {
|
validate: {
|
||||||
isSort(value: any) {
|
isSort(value: Sort) {
|
||||||
if (
|
if (
|
||||||
typeof value !== "object" ||
|
typeof value !== "object" ||
|
||||||
!value.direction ||
|
!value.direction ||
|
||||||
@@ -177,10 +181,7 @@ class Collection extends ParanoidModel {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
sort: {
|
sort: Sort | null;
|
||||||
field: string;
|
|
||||||
direction: "asc" | "desc";
|
|
||||||
};
|
|
||||||
|
|
||||||
// getters
|
// getters
|
||||||
|
|
||||||
@@ -352,7 +353,13 @@ class Collection extends ParanoidModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocumentTree = function (documentId: string): NavigationNode {
|
getDocumentTree = (documentId: string): NavigationNode | null => {
|
||||||
|
if (!this.documentStructure) return null;
|
||||||
|
const sort: Sort = this.sort || {
|
||||||
|
field: "title",
|
||||||
|
direction: "asc",
|
||||||
|
};
|
||||||
|
|
||||||
let result!: NavigationNode;
|
let result!: NavigationNode;
|
||||||
|
|
||||||
const loopChildren = (documents: NavigationNode[]) => {
|
const loopChildren = (documents: NavigationNode[]) => {
|
||||||
@@ -373,9 +380,14 @@ class Collection extends ParanoidModel {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Technically, sorting the children is presenter-layer work...
|
||||||
|
// but the only place it's used passes straight into an API response
|
||||||
|
// so the extra indirection is not worthwhile
|
||||||
loopChildren(this.documentStructure);
|
loopChildren(this.documentStructure);
|
||||||
|
return {
|
||||||
return result;
|
...result,
|
||||||
|
children: sortNavigationNodes(result.children, sort),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteDocument = async function (document: Document) {
|
deleteDocument = async function (document: Document) {
|
||||||
|
|||||||
@@ -602,6 +602,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
// Passing apiVersion=2 has a single effect, to change the response payload to
|
// Passing apiVersion=2 has a single effect, to change the response payload to
|
||||||
// include document and sharedTree keys.
|
// include document and sharedTree keys.
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
apiVersion === 2
|
apiVersion === 2
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import "../env";
|
import "../env";
|
||||||
import "@server/database/sequelize";
|
|
||||||
|
|
||||||
// test environment variables
|
// test environment variables
|
||||||
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST;
|
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST;
|
||||||
@@ -9,6 +8,10 @@ process.env.SLACK_KEY = "123";
|
|||||||
process.env.DEPLOYMENT = "";
|
process.env.DEPLOYMENT = "";
|
||||||
process.env.ALLOWED_DOMAINS = "allowed-domain.com";
|
process.env.ALLOWED_DOMAINS = "allowed-domain.com";
|
||||||
|
|
||||||
|
// NOTE: this require must come after the ENV var override above
|
||||||
|
// so that sequelize uses the test config variables
|
||||||
|
require("@server/database/sequelize");
|
||||||
|
|
||||||
// This is needed for the relative manual mock to be picked up
|
// This is needed for the relative manual mock to be picked up
|
||||||
jest.mock("../queues");
|
jest.mock("../queues");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user