From f6d889f75986e67ee56fc47a9dea7c42c1810b95 Mon Sep 17 00:00:00 2001 From: Saumya Pandey Date: Mon, 23 Aug 2021 13:07:28 +0530 Subject: [PATCH] fix: Show starred docs in sidebar (#2317) Co-authored-by: Tom Moor --- app/components/Sidebar/Main.js | 13 +- .../Sidebar/components/CollectionLink.js | 3 +- .../Sidebar/components/Collections.js | 25 ++- .../Sidebar/components/Disclosure.js | 13 ++ .../Sidebar/components/DocumentLink.js | 21 +-- .../Sidebar/components/DropCursor.js | 2 +- app/components/Sidebar/components/Header.js | 3 +- app/components/Sidebar/components/NavLink.js | 6 +- .../components/PlaceholderCollections.js | 1 + app/components/Sidebar/components/Section.js | 2 +- .../Sidebar/components/SidebarLink.js | 16 +- app/components/Sidebar/components/Starred.js | 171 ++++++++++++++++++ .../Sidebar/components/StarredLink.js | 102 +++++++++++ app/routes/authenticated.js | 4 +- app/scenes/Starred.js | 64 ------- app/utils/routeHelpers.js | 4 - shared/i18n/locales/en_US/translation.json | 7 +- shared/theme.js | 2 +- 18 files changed, 346 insertions(+), 113 deletions(-) create mode 100644 app/components/Sidebar/components/Disclosure.js create mode 100644 app/components/Sidebar/components/Starred.js create mode 100644 app/components/Sidebar/components/StarredLink.js delete mode 100644 app/scenes/Starred.js diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index ae816f0d..8c35d6dd 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -2,12 +2,11 @@ import { observer } from "mobx-react"; import { EditIcon, + SearchIcon, + ShapesIcon, HomeIcon, PlusIcon, - SearchIcon, SettingsIcon, - ShapesIcon, - StarredIcon, } from "outline-icons"; import * as React from "react"; import { DndProvider } from "react-dnd"; @@ -25,6 +24,7 @@ import ArchiveLink from "./components/ArchiveLink"; import Collections from "./components/Collections"; import Section from "./components/Section"; import SidebarLink from "./components/SidebarLink"; +import Starred from "./components/Starred"; import TeamButton from "./components/TeamButton"; import TrashLink from "./components/TrashLink"; import useCurrentTeam from "hooks/useCurrentTeam"; @@ -109,12 +109,6 @@ function MainSidebar() { label={t("Search")} exact={false} /> - } - exact={false} - label={t("Starred")} - /> {can.createDocument && ( )} +
} exact={false} + depth={0.5} menu={ <> {can.update && ( @@ -198,7 +199,7 @@ function CollectionLink({ activeDocument={activeDocument} prefetchDocument={prefetchDocument} canUpdate={canUpdate} - depth={1.5} + depth={2} index={index} /> ))} diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js index 3e877796..36f92588 100644 --- a/app/components/Sidebar/components/Collections.js +++ b/app/components/Sidebar/components/Collections.js @@ -1,16 +1,16 @@ // @flow import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; -import { PlusIcon } from "outline-icons"; +import { PlusIcon, CollapsedIcon } from "outline-icons"; import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; +import styled from "styled-components"; import Fade from "components/Fade"; import Flex from "components/Flex"; import useStores from "../../../hooks/useStores"; import CollectionLink from "./CollectionLink"; import DropCursor from "./DropCursor"; -import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; import SidebarLink from "./SidebarLink"; import useCurrentTeam from "hooks/useCurrentTeam"; @@ -25,6 +25,7 @@ function Collections({ onCreateCollection }: Props) { const [fetchError, setFetchError] = React.useState(); const { ui, policies, documents, collections } = useStores(); const { showToast } = useToasts(); + const [expanded, setExpanded] = React.useState(true); const isPreloaded: boolean = !!collections.orderedData.length; const { t } = useTranslation(); const team = useCurrentTeam(); @@ -99,6 +100,7 @@ function Collections({ onCreateCollection }: Props) { icon={} label={`${t("New collection")}…`} exact + depth={0.5} /> )} @@ -107,7 +109,11 @@ function Collections({ onCreateCollection }: Props) { if (!collections.isLoaded || fetchError) { return ( -
{t("Collections")}
+ } + disabled + />
); @@ -115,10 +121,19 @@ function Collections({ onCreateCollection }: Props) { return ( -
{t("Collections")}
- {isPreloaded ? content : {content}} + setExpanded((prev) => !prev)} + label={t("Collections")} + icon={} + /> + {expanded && (isPreloaded ? content : {content})}
); } +const Disclosure = styled(CollapsedIcon)` + transition: transform 100ms ease, fill 50ms !important; + ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; +`; + export default observer(Collections); diff --git a/app/components/Sidebar/components/Disclosure.js b/app/components/Sidebar/components/Disclosure.js new file mode 100644 index 00000000..cedba928 --- /dev/null +++ b/app/components/Sidebar/components/Disclosure.js @@ -0,0 +1,13 @@ +// @flow +import { CollapsedIcon } from "outline-icons"; +import styled from "styled-components"; + +const Disclosure = styled(CollapsedIcon)` + transition: transform 100ms ease, fill 50ms !important; + position: absolute; + left: -24px; + + ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; +`; + +export default Disclosure; diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js index 1c6d6798..cd567fbe 100644 --- a/app/components/Sidebar/components/DocumentLink.js +++ b/app/components/Sidebar/components/DocumentLink.js @@ -1,6 +1,5 @@ // @flow import { observer } from "mobx-react"; -import { CollapsedIcon } from "outline-icons"; import * as React from "react"; import { useDrag, useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; @@ -8,6 +7,7 @@ import styled from "styled-components"; import Collection from "models/Collection"; import Document from "models/Document"; import Fade from "components/Fade"; +import Disclosure from "./Disclosure"; import DropCursor from "./DropCursor"; import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; @@ -210,7 +210,7 @@ function DocumentLink( return ( <> -
+ )} -
+ {expanded && !isDragging && ( <> {node.children.map((childNode, index) => ( @@ -285,17 +286,13 @@ function DocumentLink( ); } -const Draggable = styled("div")` - opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; - pointer-events: ${(props) => (props.$isMoving ? "none" : "all")}; +const Relative = styled.div` + position: relative; `; -const Disclosure = styled(CollapsedIcon)` - transition: transform 100ms ease, fill 50ms !important; - position: absolute; - left: -24px; - - ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; +const Draggable = styled.div` + opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; + pointer-events: ${(props) => (props.$isMoving ? "none" : "all")}; `; const ObservedDocumentLink = observer(React.forwardRef(DocumentLink)); diff --git a/app/components/Sidebar/components/DropCursor.js b/app/components/Sidebar/components/DropCursor.js index d25ac49d..4e280d5d 100644 --- a/app/components/Sidebar/components/DropCursor.js +++ b/app/components/Sidebar/components/DropCursor.js @@ -27,7 +27,7 @@ const Cursor = styled("div")` width: 100%; height: 14px; - ${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")} + ${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")} background: transparent; ::after { diff --git a/app/components/Sidebar/components/Header.js b/app/components/Sidebar/components/Header.js index dbc9ccd5..e7f72744 100644 --- a/app/components/Sidebar/components/Header.js +++ b/app/components/Sidebar/components/Header.js @@ -5,10 +5,11 @@ import Flex from "components/Flex"; const Header = styled(Flex)` font-size: 11px; font-weight: 600; + user-select: none; text-transform: uppercase; color: ${(props) => props.theme.sidebarText}; letter-spacing: 0.04em; - margin: 4px 16px; + margin: 4px 12px; `; export default Header; diff --git a/app/components/Sidebar/components/NavLink.js b/app/components/Sidebar/components/NavLink.js index c0b3003a..0c2967e0 100644 --- a/app/components/Sidebar/components/NavLink.js +++ b/app/components/Sidebar/components/NavLink.js @@ -31,6 +31,7 @@ type Props = {| activeClassName?: String, activeStyle?: Object, className?: string, + scrollIntoViewIfNeeded?: boolean, exact?: boolean, isActive?: any, location?: Location, @@ -52,6 +53,7 @@ const NavLink = ({ location: locationProp, strict, style: styleProp, + scrollIntoViewIfNeeded, to, ...rest }: Props) => { @@ -83,13 +85,13 @@ const NavLink = ({ const style = isActive ? { ...styleProp, ...activeStyle } : styleProp; React.useEffect(() => { - if (isActive && linkRef.current) { + if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) { scrollIntoView(linkRef.current, { scrollMode: "if-needed", behavior: "instant", }); } - }, [linkRef, isActive]); + }, [linkRef, scrollIntoViewIfNeeded, isActive]); const props = { "aria-current": (isActive && ariaCurrent) || null, diff --git a/app/components/Sidebar/components/PlaceholderCollections.js b/app/components/Sidebar/components/PlaceholderCollections.js index 8b6375a2..3efadb1f 100644 --- a/app/components/Sidebar/components/PlaceholderCollections.js +++ b/app/components/Sidebar/components/PlaceholderCollections.js @@ -15,6 +15,7 @@ function PlaceholderCollections() { const Wrapper = styled.div` margin: 4px 16px; + margin-left: 40px; width: 75%; `; diff --git a/app/components/Sidebar/components/Section.js b/app/components/Sidebar/components/Section.js index 2469fa9d..7b1877d9 100644 --- a/app/components/Sidebar/components/Section.js +++ b/app/components/Sidebar/components/Section.js @@ -5,7 +5,7 @@ import Flex from "components/Flex"; const Section = styled(Flex)` position: relative; flex-direction: column; - margin: 0 8px 20px; + margin: 0 8px 12px; min-width: ${(props) => props.theme.sidebarMinWidth}px; flex-shrink: 0; diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js index 49be59f9..8ebe4082 100644 --- a/app/components/Sidebar/components/SidebarLink.js +++ b/app/components/Sidebar/components/SidebarLink.js @@ -28,6 +28,7 @@ type Props = { theme: Theme, exact?: boolean, depth?: number, + scrollIntoViewIfNeeded?: boolean, }; function SidebarLink( @@ -49,12 +50,13 @@ function SidebarLink( history, match, className, + scrollIntoViewIfNeeded, }: Props, ref ) { const style = React.useMemo(() => { return { - paddingLeft: `${(depth || 0) * 16 + 16}px`, + paddingLeft: `${(depth || 0) * 16 + 12}px`, }; }, [depth]); @@ -73,6 +75,7 @@ function SidebarLink( <> props.$isActiveDrop ? props.theme.slateDark : "inherit"}; color: ${(props) => @@ -156,13 +160,11 @@ const Link = styled(NavLink)` `} @media (hover: hover) { - &:hover + ${Actions}, - &:active + ${Actions} { - display: inline-flex; + &:hover + ${Actions}, &:active + ${Actions} { + display: inline-flex; - svg { - opacity: 0.75; - } + svg { + opacity: 0.75; } } diff --git a/app/components/Sidebar/components/Starred.js b/app/components/Sidebar/components/Starred.js new file mode 100644 index 00000000..778f81c9 --- /dev/null +++ b/app/components/Sidebar/components/Starred.js @@ -0,0 +1,171 @@ +// @flow +import { observer } from "mobx-react"; +import { CollapsedIcon } from "outline-icons"; +import * as React from "react"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Flex from "components/Flex"; +import PlaceholderCollections from "./PlaceholderCollections"; +import Section from "./Section"; +import SidebarLink from "./SidebarLink"; +import StarredLink from "./StarredLink"; +import useStores from "hooks/useStores"; +import useToasts from "hooks/useToasts"; + +const STARRED_PAGINATION_LIMIT = 10; +const STARRED = "STARRED"; + +function Starred() { + const [isFetching, setIsFetching] = React.useState(false); + const [fetchError, setFetchError] = React.useState(); + const [expanded, setExpanded] = React.useState(true); + const [show, setShow] = React.useState("Nothing"); + const [offset, setOffset] = React.useState(0); + const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT); + const { showToast } = useToasts(); + const { documents } = useStores(); + const { t } = useTranslation(); + const { fetchStarred, starred } = documents; + + const fetchResults = React.useCallback(async () => { + try { + setIsFetching(true); + await fetchStarred({ + limit: STARRED_PAGINATION_LIMIT, + offset, + }); + } catch (error) { + showToast(t("Starred documents could not be loaded"), { + type: "error", + }); + setFetchError(error); + } finally { + setIsFetching(false); + } + }, [fetchStarred, offset, showToast, t]); + + useEffect(() => { + let stateInLocal; + + try { + stateInLocal = localStorage.getItem(STARRED); + } catch (_) { + // no-op Safari private mode + } + + if (!stateInLocal) { + localStorage.setItem(STARRED, expanded ? "true" : "false"); + } else { + setExpanded(stateInLocal === "true"); + } + }, [expanded]); + + useEffect(() => { + setOffset(starred.length); + if (starred.length <= STARRED_PAGINATION_LIMIT) { + setShow("Nothing"); + } else if (starred.length >= upperBound) { + setShow("More"); + } else if (starred.length < upperBound) { + setShow("Less"); + } + }, [starred, upperBound]); + + useEffect(() => { + if (offset === 0) { + fetchResults(); + } + }, [fetchResults, offset]); + + const handleShowMore = React.useCallback( + async (ev) => { + setUpperBound( + (previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT + ); + await fetchResults(); + }, + [fetchResults] + ); + + const handleShowLess = React.useCallback((ev) => { + setUpperBound(STARRED_PAGINATION_LIMIT); + setShow("More"); + }, []); + + const handleExpandClick = React.useCallback( + (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + try { + localStorage.setItem(STARRED, !expanded ? "true" : "false"); + } catch (_) { + // no-op Safari private mode + } + setExpanded((prev) => !prev); + }, + [expanded] + ); + + const content = starred.slice(0, upperBound).map((document, index) => { + return ( + + ); + }); + + if (!starred.length) { + return null; + } + + return ( +
+ + } + /> + {expanded && ( + <> + {content} + {show === "More" && !isFetching && ( + + )} + {show === "Less" && !isFetching && ( + + )} + {(isFetching || fetchError) && ( + + + + )} + + )} + +
+ ); +} + +const Disclosure = styled(CollapsedIcon)` + transition: transform 100ms ease, fill 50ms !important; + ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; +`; + +export default observer(Starred); diff --git a/app/components/Sidebar/components/StarredLink.js b/app/components/Sidebar/components/StarredLink.js new file mode 100644 index 00000000..55d53dca --- /dev/null +++ b/app/components/Sidebar/components/StarredLink.js @@ -0,0 +1,102 @@ +// @flow +import { observer } from "mobx-react"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import styled from "styled-components"; +import Fade from "components/Fade"; +import useStores from "../../../hooks/useStores"; +import Disclosure from "./Disclosure"; +import SidebarLink from "./SidebarLink"; +import useBoolean from "hooks/useBoolean"; +import DocumentMenu from "menus/DocumentMenu"; + +type Props = {| + depth: number, + title: string, + to: string, + documentId: string, + collectionId: string, +|}; + +function StarredLink({ depth, title, to, documentId, collectionId }: Props) { + const { collections, documents } = useStores(); + const collection = collections.get(collectionId); + const document = documents.get(documentId); + const [expanded, setExpanded] = useState(false); + const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); + + const childDocuments = collection + ? collection.getDocumentChildren(documentId) + : []; + + const hasChildDocuments = childDocuments.length > 0; + + useEffect(() => { + async function load() { + if (!document) { + await documents.fetch(documentId); + } + } + load(); + }, [collection, collectionId, collections, document, documentId, documents]); + + const handleDisclosureClick = React.useCallback((ev: SyntheticEvent<>) => { + ev.preventDefault(); + ev.stopPropagation(); + setExpanded((prevExpanded) => !prevExpanded); + }, []); + + return ( + <> + + + {hasChildDocuments && ( + + )} + {title} + + } + exact={false} + showActions={menuOpen} + menu={ + document ? ( + + + + ) : undefined + } + /> + + {expanded && + childDocuments.map((childDocument) => ( + + ))} + + ); +} + +const Relative = styled.div` + position: relative; +`; + +const ObserveredStarredLink = observer(StarredLink); + +export default ObserveredStarredLink; diff --git a/app/routes/authenticated.js b/app/routes/authenticated.js index 8e5201cd..7b9dd1bb 100644 --- a/app/routes/authenticated.js +++ b/app/routes/authenticated.js @@ -8,7 +8,6 @@ import Drafts from "scenes/Drafts"; import Error404 from "scenes/Error404"; import Home from "scenes/Home"; import Search from "scenes/Search"; -import Starred from "scenes/Starred"; import Templates from "scenes/Templates"; import Trash from "scenes/Trash"; @@ -51,13 +50,12 @@ export default function AuthenticatedRoutes() { - - + diff --git a/app/scenes/Starred.js b/app/scenes/Starred.js deleted file mode 100644 index c875340a..00000000 --- a/app/scenes/Starred.js +++ /dev/null @@ -1,64 +0,0 @@ -// @flow -import { observer } from "mobx-react"; -import { StarredIcon } from "outline-icons"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { type Match } from "react-router-dom"; -import { Action } from "components/Actions"; -import Empty from "components/Empty"; -import Heading from "components/Heading"; -import InputSearchPage from "components/InputSearchPage"; -import PaginatedDocumentList from "components/PaginatedDocumentList"; -import Scene from "components/Scene"; -import Tab from "components/Tab"; -import Tabs from "components/Tabs"; -import useStores from "hooks/useStores"; -import NewDocumentMenu from "menus/NewDocumentMenu"; - -type Props = { - match: Match, -}; - -function Starred(props: Props) { - const { documents } = useStores(); - const { t } = useTranslation(); - const { fetchStarred, starred, starredAlphabetical } = documents; - const { sort } = props.match.params; - - return ( - } - title={t("Starred")} - actions={ - <> - - - - - - - - } - > - {t("Starred")} - - - {t("Recently updated")} - - - {t("Alphabetical")} - - - } - empty={{t("You’ve not starred any documents yet.")}} - fetch={fetchStarred} - documents={sort === "alphabetical" ? starredAlphabetical : starred} - showCollection - /> - - ); -} - -export default observer(Starred); diff --git a/app/utils/routeHelpers.js b/app/utils/routeHelpers.js index f31557d3..d7566f0f 100644 --- a/app/utils/routeHelpers.js +++ b/app/utils/routeHelpers.js @@ -7,10 +7,6 @@ export function homeUrl(): string { return "/home"; } -export function starredUrl(): string { - return "/starred"; -} - export function newCollectionUrl(): string { return "/collection/new"; } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 33e6572a..a0bef38f 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -141,9 +141,12 @@ "Collections": "Collections", "Untitled": "Untitled", "Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word", + "Starred documents could not be loaded": "Starred documents could not be loaded", + "Starred": "Starred", + "Show more": "Show more", + "Show less": "Show less", "Delete {{ documentName }}": "Delete {{ documentName }}", "Home": "Home", - "Starred": "Starred", "Settings": "Settings", "Invite people": "Invite people", "Create a collection": "Create a collection", @@ -175,6 +178,7 @@ "System": "System", "Light": "Light", "Dark": "Dark", + "Switch team": "Switch team", "Log out": "Log out", "Show path to document": "Show path to document", "Path to document": "Path to document", @@ -551,7 +555,6 @@ "Zapier": "Zapier", "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'", "Open Zapier": "Open Zapier", - "You’ve not starred any documents yet.": "You’ve not starred any documents yet.", "There are no templates just yet.": "There are no templates just yet.", "You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.", "Trash is empty at the moment.": "Trash is empty at the moment.", diff --git a/shared/theme.js b/shared/theme.js index 56f51e87..0e355085 100644 --- a/shared/theme.js +++ b/shared/theme.js @@ -198,7 +198,7 @@ export const dark = { placeholder: colors.slateDark, sidebarBackground: colors.veryDarkBlue, - sidebarItemBackground: colors.transparent, + sidebarItemBackground: lighten(0.015, colors.almostBlack), sidebarText: colors.slate, backdrop: "rgba(255, 255, 255, 0.3)", shadow: "rgba(0, 0, 0, 0.6)",