From 71820fb3add030f4417041463aa15737de96ab73 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Fri, 14 Jan 2022 19:02:01 -0800 Subject: [PATCH] feat: Add navigation sidebar to shared documents (#2899) Co-authored-by: Tom Moor --- app/components/AuthenticatedLayout.tsx | 100 +++++++++++ app/components/Branding.tsx | 1 + app/components/Button.tsx | 6 +- app/components/Layout.tsx | 169 ++++++------------ app/components/Sidebar/Shared.tsx | 35 ++++ app/components/Sidebar/components/NavLink.tsx | 1 + .../Sidebar/components/SharedDocumentLink.tsx | 128 +++++++++++++ app/routes/authenticated.tsx | 2 +- app/routes/index.tsx | 2 + app/scenes/Document/Shared.tsx | 33 ++-- app/scenes/Document/components/DataLoader.tsx | 5 +- app/scenes/Document/components/References.tsx | 10 +- app/stores/DocumentsStore.ts | 32 +++- app/types.ts | 5 + server/models/Collection.test.ts | 1 + server/models/Collection.ts | 30 +++- server/routes/api/documents.ts | 1 + server/test/setup.ts | 5 +- 18 files changed, 408 insertions(+), 158 deletions(-) create mode 100644 app/components/AuthenticatedLayout.tsx create mode 100644 app/components/Sidebar/Shared.tsx create mode 100644 app/components/Sidebar/components/SharedDocumentLink.tsx diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx new file mode 100644 index 000000000..7330c5cf9 --- /dev/null +++ b/app/components/AuthenticatedLayout.tsx @@ -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 { + 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 ; + + const sidebar = showSidebar ? ( + + + + + ) : undefined; + + const rightRail = ( + + + + + + ); + + return ( + + + + + {this.props.children} + + + ); + } +} + +export default withTranslation()(withStores(AuthenticatedLayout)); diff --git a/app/components/Branding.tsx b/app/components/Branding.tsx index 756130eee..21dd5005a 100644 --- a/app/components/Branding.tsx +++ b/app/components/Branding.tsx @@ -17,6 +17,7 @@ function Branding({ href = env.URL }: Props) { } const Link = styled.a` + z-index: ${(props) => props.theme.depths.sidebar + 1}; position: fixed; bottom: 0; left: 0; diff --git a/app/components/Button.tsx b/app/components/Button.tsx index baf6e5ef5..ff7caea04 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -6,7 +6,7 @@ import styled from "styled-components"; const RealButton = styled.button<{ fullwidth?: boolean; borderOnHover?: boolean; - neutral?: boolean; + $neutral?: boolean; danger?: boolean; iconColor?: string; }>` @@ -55,7 +55,7 @@ const RealButton = styled.button<{ } ${(props) => - props.neutral && + props.$neutral && ` background: ${props.theme.buttonNeutralBackground}; color: ${props.theme.buttonNeutralText}; @@ -158,7 +158,7 @@ const Button = ( const hasIcon = icon !== undefined; return ( - + {hasIcon && icon} {hasText && } diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx index 39c8a8ab4..7e9b9ae88 100644 --- a/app/components/Layout.tsx +++ b/app/components/Layout.tsx @@ -1,151 +1,80 @@ -import { observable } from "mobx"; import { observer } from "mobx-react"; import { MenuIcon } from "outline-icons"; import * as React from "react"; import { Helmet } from "react-helmet"; -import { withTranslation, WithTranslation } from "react-i18next"; -import { Switch, Route } from "react-router-dom"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; -import RootStore from "~/stores/RootStore"; -import ErrorSuspended from "~/scenes/ErrorSuspended"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; 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 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 { - 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 = { + title?: string; + children?: React.ReactNode; + sidebar?: React.ReactNode; + rightRail?: React.ReactNode; +}; -type Props = WithTranslation & - RootStore & { - children?: React.ReactNode; - }; +function Layout({ title, children, sidebar, rightRail }: Props) { + const { ui } = useStores(); + const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed; -@observer -class Layout extends React.Component { - scrollable: HTMLDivElement | null | undefined; - - @observable - keyboardShortcutsOpen = false; - - goToSearch = (ev: KeyboardEvent) => { - if (!ev.metaKey && !ev.ctrlKey) { - ev.preventDefault(); - ev.stopPropagation(); - history.push(searchUrl()); + useKeyDown(".", (event) => { + if (isModKey(event)) { + ui.toggleCollapsedSidebar(); } - }; + }); - goToNewDocument = () => { - const { activeCollectionId } = this.props.ui; - if (!activeCollectionId) return; - const can = this.props.policies.abilities(activeCollectionId); - if (!can.update) return; - history.push(newDocumentPath(activeCollectionId)); - }; + return ( + + + {title ? title : "Outline"} + + - render() { - 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 ; + - return ( - - - - - { - if (isModKey(event)) { - ui.toggleCollapsedSidebar(); - } - }} - /> - - {team && team.name ? team.name : "Outline"} - - - - - {this.props.ui.progressBarVisible && } + {ui.progressBarVisible && } + {sidebar && ( } iconColor="currentColor" neutral /> + )} - - {showSidebar && ( - - - - - )} + + {sidebar} - - - {this.props.children} - + + + {children} + - - - - - - - + {rightRail} - ); - } + + ); } const Container = styled(Flex)` @@ -174,6 +103,7 @@ const MobileMenuButton = styled(Button)` const Content = styled(Flex)<{ $isResizing?: boolean; $sidebarCollapsed?: boolean; + $hasSidebar?: boolean; }>` margin: 0; transition: ${(props) => @@ -189,9 +119,10 @@ const Content = styled(Flex)<{ ${breakpoint("tablet")` ${(props: any) => + props.$hasSidebar && props.$sidebarCollapsed && `margin-left: ${props.theme.sidebarCollapsedWidth}px;`} `}; `; -export default withTranslation()(withStores(Layout)); +export default observer(Layout); diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx new file mode 100644 index 000000000..f0f106071 --- /dev/null +++ b/app/components/Sidebar/Shared.tsx @@ -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 ( + + +
+ +
+
+
+ ); +} + +export default observer(SharedSidebar); diff --git a/app/components/Sidebar/components/NavLink.tsx b/app/components/Sidebar/components/NavLink.tsx index b6d4d6596..7ba8010ec 100644 --- a/app/components/Sidebar/components/NavLink.tsx +++ b/app/components/Sidebar/components/NavLink.tsx @@ -78,6 +78,7 @@ const NavLink = ({ strict, }) : null; + const isActive = !!(isActiveProp ? isActiveProp(match, currentLocation) : match); diff --git a/app/components/Sidebar/components/SharedDocumentLink.tsx b/app/components/Sidebar/components/SharedDocumentLink.tsx new file mode 100644 index 000000000..fd1b88e1e --- /dev/null +++ b/app/components/Sidebar/components/SharedDocumentLink.tsx @@ -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 +) { + 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 ( + <> + + {hasChildDocuments && ( + + )} + {title} + + } + depth={depth} + exact={false} + scrollIntoViewIfNeeded={!document?.isStarred} + isDraft={isDraft} + ref={ref} + isActive={() => { + return !!isActiveDocument; + }} + /> + {expanded && + nodeChildren.map((childNode, index) => ( + + ))} + + ); +} + +const ObservedDocumentLink = observer(React.forwardRef(DocumentLink)); + +export default ObservedDocumentLink; diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index da211f9ea..309f2c10d 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -8,8 +8,8 @@ import Error404 from "~/scenes/Error404"; import Search from "~/scenes/Search"; import Templates from "~/scenes/Templates"; import Trash from "~/scenes/Trash"; +import Layout from "~/components/AuthenticatedLayout"; import CenteredContent from "~/components/CenteredContent"; -import Layout from "~/components/Layout"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import Route from "~/components/ProfiledRoute"; import SocketProvider from "~/components/SocketProvider"; diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 967db33c5..c66d06b43 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -54,12 +54,14 @@ export default function Routes() { + + diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index 92c102cca..61bd6f25f 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -1,10 +1,13 @@ import { Location } from "history"; +import { observer } from "mobx-react"; import * as React from "react"; import { RouteComponentProps } from "react-router-dom"; import { useTheme } from "styled-components"; import DocumentModel from "~/models/Document"; import Error404 from "~/scenes/Error404"; import ErrorOffline from "~/scenes/ErrorOffline"; +import Layout from "~/components/Layout"; +import Sidebar from "~/components/Sidebar/Shared"; import useStores from "~/hooks/useStores"; import { NavigationNode } from "~/types"; import { OfflineError } from "~/utils/errors"; @@ -20,7 +23,9 @@ type Props = RouteComponentProps<{ location: Location<{ title?: string }>; }; -export default function SharedDocumentScene(props: Props) { +function SharedDocumentScene(props: Props) { + const { ui } = useStores(); + const theme = useTheme(); const [response, setResponse] = React.useState<{ document: DocumentModel; @@ -42,13 +47,13 @@ export default function SharedDocumentScene(props: Props) { shareId, }); setResponse(response); + ui.setActiveDocument(response.document); } catch (err) { setError(err); } } - fetchData(); - }, [documents, documentSlug, shareId]); + }, [documents, documentSlug, shareId, ui]); if (error) { return error instanceof OfflineError ? : ; @@ -58,13 +63,21 @@ export default function SharedDocumentScene(props: Props) { return ; } + const sidebar = response.sharedTree ? ( + + ) : undefined; + return ( - + + + ); } + +export default observer(SharedDocumentScene); diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 8bdfe9c33..86e8d596b 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -36,8 +36,6 @@ type Props = RootStore & children: (arg0: any) => React.ReactNode; }; -const sharedTreeCache = {}; - @observer class DataLoader extends React.Component { sharedTree: NavigationNode | null | undefined; @@ -58,7 +56,7 @@ class DataLoader extends React.Component { const { documents, match } = this.props; this.document = documents.getByUrl(match.params.documentSlug); this.sharedTree = this.document - ? sharedTreeCache[this.document.id] + ? documents.getSharedTree(this.document.id) : undefined; this.loadDocument(); } @@ -192,7 +190,6 @@ class DataLoader extends React.Component { ); this.sharedTree = response.sharedTree; this.document = response.document; - sharedTreeCache[this.document.id] = response.sharedTree; if (revisionId && revisionId !== "latest") { await this.loadRevision(); diff --git a/app/scenes/Document/components/References.tsx b/app/scenes/Document/components/References.tsx index c41f360c9..008999aa6 100644 --- a/app/scenes/Document/components/References.tsx +++ b/app/scenes/Document/components/References.tsx @@ -21,19 +21,19 @@ function References({ document }: Props) { documents.fetchBacklinks(document.id); }, [documents, document.id]); - const backlinks = documents.getBacklinedDocuments(document.id); + const backlinks = documents.getBacklinkedDocuments(document.id); const collection = collections.get(document.collectionId); const children = collection ? collection.getDocumentChildren(document.id) : []; const showBacklinks = !!backlinks.length; - const showParentDocuments = !!children.length; - const isBacklinksTab = location.hash === "#backlinks" || !showParentDocuments; + const showChildDocuments = !!children.length; + const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments; - return showBacklinks || showParentDocuments ? ( + return showBacklinks || showChildDocuments ? ( - {showParentDocuments && ( + {showChildDocuments && ( !isBacklinksTab}> Nested documents diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 6adedf55c..39deefc9b 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -11,10 +11,10 @@ import RootStore from "~/stores/RootStore"; import Document from "~/models/Document"; import env from "~/env"; import { - NavigationNode, FetchOptions, PaginationParams, SearchResult, + NavigationNode, } from "~/types"; import { client } from "~/utils/ApiClient"; @@ -38,6 +38,8 @@ type ImportOptions = { }; export default class DocumentsStore extends BaseStore { + sharedTreeCache: Map = new Map(); + @observable searchCache: Map = new Map(); @@ -262,7 +264,7 @@ export default class DocumentsStore extends BaseStore { }); }; - getBacklinedDocuments(documentId: string): Document[] { + getBacklinkedDocuments(documentId: string): Document[] { const documentIds = this.backlinks.get(documentId) || []; return orderBy( compact(documentIds.map((id) => this.data.get(id))), @@ -271,6 +273,10 @@ export default class DocumentsStore extends BaseStore { ); } + getSharedTree(documentId: string): NavigationNode | undefined { + return this.sharedTreeCache.get(documentId); + } + @action fetchChildDocuments = async (documentId: string): Promise => { const res = await client.post(`/documents.list`, { @@ -468,9 +474,16 @@ export default class DocumentsStore extends BaseStore { const policy = doc ? this.rootStore.policies.get(doc.id) : undefined; if (doc && policy && !options.force) { - return { - document: doc, - }; + if (!options.shareId) { + 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", { @@ -486,9 +499,16 @@ export default class DocumentsStore extends BaseStore { const document = this.data.get(res.data.document.id); invariant(document, "Document not available"); + if (options.shareId) { + this.sharedTreeCache.set(options.shareId, res.data.sharedTree); + return { + document, + sharedTree: res.data.sharedTree, + }; + } + return { document, - sharedTree: res.data.sharedTree, }; } finally { this.isFetching = false; diff --git a/app/types.ts b/app/types.ts index 1cc3ebd97..0388c3e1e 100644 --- a/app/types.ts +++ b/app/types.ts @@ -139,6 +139,11 @@ export type NavigationNode = { isDraft?: boolean; }; +export type CollectionSort = { + field: string; + direction: "asc" | "desc"; +}; + // Pagination response in an API call export type Pagination = { limit: number; diff --git a/server/models/Collection.test.ts b/server/models/Collection.test.ts index 3e1ede72b..48630167f 100644 --- a/server/models/Collection.test.ts +++ b/server/models/Collection.test.ts @@ -76,6 +76,7 @@ describe("getDocumentTree", () => { { ...parent.toJSON(), children: [document.toJSON()] }, ], }); + expect(collection.getDocumentTree(parent.id)).toEqual({ ...parent.toJSON(), children: [document.toJSON()], diff --git a/server/models/Collection.ts b/server/models/Collection.ts index dadf868ec..bc8afd7b0 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -26,9 +26,10 @@ import { DataType, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; +import { sortNavigationNodes } from "@shared/utils/collections"; import { SLUG_URL_REGEX } from "@shared/utils/routeHelpers"; import slugify from "@server/utils/slugify"; -import { NavigationNode } from "~/types"; +import { NavigationNode, CollectionSort } from "~/types"; import CollectionGroup from "./CollectionGroup"; import CollectionUser from "./CollectionUser"; import Document from "./Document"; @@ -39,6 +40,9 @@ import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; +// without this indirection, the app crashes on starup +type Sort = CollectionSort; + @Scopes(() => ({ withAllMemberships: { include: [ @@ -157,7 +161,7 @@ class Collection extends ParanoidModel { @Column({ type: DataType.JSONB, validate: { - isSort(value: any) { + isSort(value: Sort) { if ( typeof value !== "object" || !value.direction || @@ -177,10 +181,7 @@ class Collection extends ParanoidModel { }, }, }) - sort: { - field: string; - direction: "asc" | "desc"; - }; + sort: Sort | null; // 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; 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); - - return result; + return { + ...result, + children: sortNavigationNodes(result.children, sort), + }; }; deleteDocument = async function (document: Document) { diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts index f6a59f4b0..06582b582 100644 --- a/server/routes/api/documents.ts +++ b/server/routes/api/documents.ts @@ -602,6 +602,7 @@ router.post( }); // Passing apiVersion=2 has a single effect, to change the response payload to // include document and sharedTree keys. + const data = apiVersion === 2 ? { diff --git a/server/test/setup.ts b/server/test/setup.ts index 3fef84f80..0c44f5d12 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -1,5 +1,4 @@ import "../env"; -import "@server/database/sequelize"; // test environment variables process.env.DATABASE_URL = process.env.DATABASE_URL_TEST; @@ -9,6 +8,10 @@ process.env.SLACK_KEY = "123"; process.env.DEPLOYMENT = ""; 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 jest.mock("../queues");