diff --git a/app/actions/definitions/collections.tsx b/app/actions/definitions/collections.tsx index 2d3288894..a151d2c79 100644 --- a/app/actions/definitions/collections.tsx +++ b/app/actions/definitions/collections.tsx @@ -1,4 +1,10 @@ -import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons"; +import { + CollectionIcon, + EditIcon, + PlusIcon, + StarredIcon, + UnstarredIcon, +} from "outline-icons"; import * as React from "react"; import stores from "~/stores"; import Collection from "~/models/Collection"; @@ -73,4 +79,59 @@ export const editCollection = createAction({ }, }); -export const rootCollectionActions = [openCollection, createCollection]; +export const starCollection = createAction({ + name: ({ t }) => t("Star"), + section: CollectionSection, + icon: , + keywords: "favorite bookmark", + visible: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return false; + } + const collection = stores.collections.get(activeCollectionId); + return ( + !collection?.isStarred && + stores.policies.abilities(activeCollectionId).star + ); + }, + perform: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return; + } + + const collection = stores.collections.get(activeCollectionId); + collection?.star(); + }, +}); + +export const unstarCollection = createAction({ + name: ({ t }) => t("Unstar"), + section: CollectionSection, + icon: , + keywords: "unfavorite unbookmark", + visible: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return false; + } + const collection = stores.collections.get(activeCollectionId); + return ( + !!collection?.isStarred && + stores.policies.abilities(activeCollectionId).unstar + ); + }, + perform: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return; + } + + const collection = stores.collections.get(activeCollectionId); + collection?.unstar(); + }, +}); + +export const rootCollectionActions = [ + openCollection, + createCollection, + starCollection, + unstarCollection, +]; diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 7896f7dcf..47a16500c 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -52,8 +52,11 @@ export const createDocument = createAction({ visible: ({ activeCollectionId, stores }) => !!activeCollectionId && stores.policies.abilities(activeCollectionId).update, - perform: ({ activeCollectionId }) => - activeCollectionId && history.push(newDocumentPath(activeCollectionId)), + perform: ({ activeCollectionId, inStarredSection }) => + activeCollectionId && + history.push(newDocumentPath(activeCollectionId), { + starred: inStarredSection, + }), }); export const starDocument = createAction({ diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 6484b9410..23ef51dfd 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -1,18 +1,15 @@ -import fractionalIndex from "fractional-index"; +import { Location } from "history"; import { observer } from "mobx-react"; import { PlusIcon } from "outline-icons"; import * as React from "react"; -import { useDrop, useDrag, DropTargetMonitor } from "react-dnd"; +import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; -import { useLocation, useHistory } from "react-router-dom"; -import styled from "styled-components"; -import { sortNavigationNodes } from "@shared/utils/collections"; +import { useHistory } from "react-router-dom"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import DocumentReparent from "~/scenes/DocumentReparent"; import CollectionIcon from "~/components/CollectionIcon"; import Fade from "~/components/Fade"; -import Modal from "~/components/Modal"; import NudeButton from "~/components/NudeButton"; import { createDocument } from "~/actions/definitions/documents"; import useActionContext from "~/hooks/useActionContext"; @@ -21,40 +18,36 @@ import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import CollectionMenu from "~/menus/CollectionMenu"; import { NavigationNode } from "~/types"; -import DocumentLink from "./DocumentLink"; -import DropCursor from "./DropCursor"; import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; +import Relative from "./Relative"; import SidebarLink, { DragObject } from "./SidebarLink"; +import { useStarredContext } from "./StarredContext"; type Props = { collection: Collection; - canUpdate: boolean; - activeDocument: Document | null | undefined; - prefetchDocument: (id: string) => Promise; - belowCollection: Collection | void; + expanded?: boolean; + onDisclosureClick: (ev: React.MouseEvent) => void; + activeDocument: Document | undefined; + isDraggingAnyCollection?: boolean; }; -function CollectionLink({ +const CollectionLink: React.FC = ({ collection, - activeDocument, - prefetchDocument, - canUpdate, - belowCollection, -}: Props) { - const history = useHistory(); - const { t } = useTranslation(); - const { search } = useLocation(); - const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); - const [ - permissionOpen, - handlePermissionOpen, - handlePermissionClose, - ] = useBoolean(); + expanded, + onDisclosureClick, + isDraggingAnyCollection, +}) => { const itemRef = React.useRef< NavigationNode & { depth: number; active: boolean; collectionId: string } >(); + const { dialogs, documents, collections } = useStores(); + const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [isEditing, setIsEditing] = React.useState(false); + const canUpdate = usePolicy(collection.id).update; + const { t } = useTranslation(); + const history = useHistory(); + const inStarredSection = useStarredContext(); const handleTitleChange = React.useCallback( async (name: string) => { @@ -66,26 +59,6 @@ function CollectionLink({ [collection, history] ); - const handleTitleEditing = React.useCallback((isEditing: boolean) => { - setIsEditing(isEditing); - }, []); - - const { ui, documents, collections } = useStores(); - const [expanded, setExpanded] = React.useState( - collection.id === ui.activeCollectionId - ); - - const [openedOnce, setOpenedOnce] = React.useState(expanded); - React.useEffect(() => { - if (expanded) { - setOpenedOnce(true); - } - }, [expanded]); - - const manualSort = collection.sort.field === "index"; - const can = usePolicy(collection.id); - const belowCollectionIndex = belowCollection ? belowCollection.index : null; - // Drop to re-parent document const [{ isOver, canDrop }, drop] = useDrop({ accept: "document", @@ -111,14 +84,23 @@ function CollectionLink({ prevCollection.permission !== collection.permission ) { itemRef.current = item; - handlePermissionOpen(); + + dialogs.openModal({ + title: t("Move document"), + content: ( + + ), + }); } else { documents.move(id, collection.id); } }, - canDrop: () => { - return can.update; - }, + canDrop: () => canUpdate, collect: (monitor) => ({ isOver: !!monitor.isOver({ shallow: true, @@ -127,224 +109,69 @@ function CollectionLink({ }), }); - // Drop to reorder document - const [{ isOverReorder }, dropToReorder] = useDrop({ - accept: "document", - drop: (item: DragObject) => { - if (!collection) { - return; - } - documents.move(item.id, collection.id, undefined, 0); - }, - collect: (monitor) => ({ - isOverReorder: !!monitor.isOver(), - }), - }); - - // Drop to reorder collection - const [ - { isCollectionDropping, isDraggingAnyCollection }, - dropToReorderCollection, - ] = useDrop({ - accept: "collection", - drop: (item: DragObject) => { - collections.move( - item.id, - fractionalIndex(collection.index, belowCollectionIndex) - ); - }, - canDrop: (item) => { - return ( - collection.id !== item.id && - (!belowCollection || item.id !== belowCollection.id) - ); - }, - collect: (monitor: DropTargetMonitor) => ({ - isCollectionDropping: monitor.isOver(), - isDraggingAnyCollection: monitor.getItemType() === "collection", - }), - }); - - // Drag to reorder collection - const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({ - type: "collection", - item: () => { - return { - id: collection.id, - }; - }, - collect: (monitor) => ({ - isCollectionDragging: monitor.isDragging(), - }), - canDrag: () => { - return can.move; - }, - }); - - const collectionDocuments = React.useMemo(() => { - if ( - activeDocument?.isActive && - activeDocument?.isDraft && - activeDocument?.collectionId === collection.id && - !activeDocument?.parentDocumentId - ) { - return sortNavigationNodes( - [activeDocument.asNavigationNode, ...collection.documents], - collection.sort - ); - } - - return collection.documents; - }, [ - activeDocument?.isActive, - activeDocument?.isDraft, - activeDocument?.collectionId, - activeDocument?.parentDocumentId, - activeDocument?.asNavigationNode, - collection.documents, - collection.id, - collection.sort, - ]); - - const displayDocumentLinks = expanded && !isCollectionDragging; - - React.useEffect(() => { - // If we're viewing a starred document through the starred menu then don't - // touch the expanded / collapsed state of the collections - if (search === "?starred") { - return; - } - - if (collection.id === ui.activeCollectionId) { - setExpanded(true); - } - }, [collection.id, ui.activeCollectionId, search]); + const handleTitleEditing = React.useCallback((isEditing: boolean) => { + setIsEditing(isEditing); + }, []); const context = useActionContext({ activeCollectionId: collection.id, + inStarredSection, }); return ( <> - - - { - event.preventDefault(); - setExpanded((prev) => !prev); - }} - icon={ - - } - showActions={menuOpen} - isActiveDrop={isOver && canDrop} - label={ - - } - exact={false} - depth={0} - menu={ - !isEditing && - !isDraggingAnyCollection && ( - - - - - - - ) - } - /> - - - - - {openedOnce && ( - - {manualSort && ( - - )} - {collectionDocuments.map((node, index) => ( - + + } + showActions={menuOpen} + isActiveDrop={isOver && canDrop} + isActive={(match, location: Location<{ starred?: boolean }>) => + !!match && location.state?.starred === inStarredSection + } + label={ + - ))} - - )} - {isDraggingAnyCollection && ( - + + + + + + ) + } /> - )} + - - - {itemRef.current && ( - - )} - ); -} - -const Relative = styled.div` - position: relative; -`; - -const Folder = styled.div<{ $open?: boolean }>` - display: ${(props) => (props.$open ? "block" : "none")}; -`; - -const Draggable = styled("div")<{ $isDragging: boolean; $isMoving: boolean }>` - opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; - pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")}; -`; +}; export default observer(CollectionLink); diff --git a/app/components/Sidebar/components/CollectionLinkChildren.tsx b/app/components/Sidebar/components/CollectionLinkChildren.tsx new file mode 100644 index 000000000..4a1cad584 --- /dev/null +++ b/app/components/Sidebar/components/CollectionLinkChildren.tsx @@ -0,0 +1,91 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useDrop } from "react-dnd"; +import { useTranslation } from "react-i18next"; +import Collection from "~/models/Collection"; +import Document from "~/models/Document"; +import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; +import DocumentLink from "./DocumentLink"; +import DropCursor from "./DropCursor"; +import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder"; +import Folder from "./Folder"; +import { DragObject } from "./SidebarLink"; +import useCollectionDocuments from "./useCollectionDocuments"; + +type Props = { + collection: Collection; + expanded: boolean; + prefetchDocument?: (documentId: string) => Promise; +}; + +function CollectionLinkChildren({ + collection, + expanded, + prefetchDocument, +}: Props) { + const can = usePolicy(collection.id); + const { showToast } = useToasts(); + const manualSort = collection.sort.field === "index"; + const { documents } = useStores(); + const { t } = useTranslation(); + + const childDocuments = useCollectionDocuments(collection, documents.active); + + // Drop to reorder document + const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({ + accept: "document", + drop: (item: DragObject) => { + if (!manualSort) { + showToast( + t( + "You can't reorder documents in an alphabetically sorted collection" + ), + { + type: "info", + timeout: 5000, + } + ); + return; + } + + if (!collection) { + return; + } + documents.move(item.id, collection.id, undefined, 0); + }, + collect: (monitor) => ({ + isOverReorder: !!monitor.isOver(), + isDraggingAnyDocument: !!monitor.canDrop(), + }), + }); + + return ( + + {isDraggingAnyDocument && can.update && ( + + )} + {childDocuments.map((node, index) => ( + + ))} + {childDocuments.length === 0 && } + + ); +} + +export default observer(CollectionLinkChildren); diff --git a/app/components/Sidebar/components/Collections.tsx b/app/components/Sidebar/components/Collections.tsx index 270d61f3c..b2311d2d9 100644 --- a/app/components/Sidebar/components/Collections.tsx +++ b/app/components/Sidebar/components/Collections.tsx @@ -3,24 +3,24 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; -import styled from "styled-components"; import Collection from "~/models/Collection"; import Fade from "~/components/Fade"; import Flex from "~/components/Flex"; import { createCollection } from "~/actions/definitions/collections"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; -import CollectionLink from "./CollectionLink"; +import DraggableCollectionLink from "./DraggableCollectionLink"; import DropCursor from "./DropCursor"; import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; +import Relative from "./Relative"; import SidebarAction from "./SidebarAction"; import { DragObject } from "./SidebarLink"; function Collections() { const [isFetching, setFetching] = React.useState(false); const [fetchError, setFetchError] = React.useState(); - const { policies, documents, collections } = useStores(); + const { documents, collections } = useStores(); const { showToast } = useToasts(); const [expanded, setExpanded] = React.useState(true); const isPreloaded = !!collections.orderedData.length; @@ -82,12 +82,11 @@ function Collections() { /> )} {orderedCollections.map((collection: Collection, index: number) => ( - ))} @@ -116,8 +115,4 @@ function Collections() { ); } -const Relative = styled.div` - position: relative; -`; - export default observer(Collections); diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 772ff9973..a68afb20f 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -1,3 +1,4 @@ +import { Location } from "history"; import { observer } from "mobx-react"; import { PlusIcon } from "outline-icons"; import * as React from "react"; @@ -13,6 +14,7 @@ import Fade from "~/components/Fade"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import useBoolean from "~/hooks/useBoolean"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import DocumentMenu from "~/menus/DocumentMenu"; @@ -21,24 +23,25 @@ import { newDocumentPath } from "~/utils/routeHelpers"; import DropCursor from "./DropCursor"; import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; +import Folder from "./Folder"; +import Relative from "./Relative"; import SidebarLink, { DragObject } from "./SidebarLink"; +import { useStarredContext } from "./StarredContext"; type Props = { node: NavigationNode; - canUpdate: boolean; collection?: Collection; activeDocument: Document | null | undefined; - prefetchDocument: (documentId: string) => Promise; + prefetchDocument?: (documentId: string) => Promise; isDraft?: boolean; depth: number; index: number; parentId?: string; }; -function DocumentLink( +function InnerDocumentLink( { node, - canUpdate, collection, activeDocument, prefetchDocument, @@ -52,12 +55,14 @@ function DocumentLink( const { showToast } = useToasts(); const { documents, policies } = useStores(); const { t } = useTranslation(); + const canUpdate = usePolicy(node.id).update; const isActiveDocument = activeDocument && activeDocument.id === node.id; const hasChildDocuments = !!node.children.length || activeDocument?.parentDocumentId === node.id; const document = documents.get(node.id); const { fetchChildDocuments } = documents; const [isEditing, setIsEditing] = React.useState(false); + const inStarredSection = useStarredContext(); React.useEffect(() => { if (isActiveDocument && hasChildDocuments) { @@ -84,7 +89,6 @@ function DocumentLink( }, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]); const [expanded, setExpanded] = React.useState(showChildren); - const [openedOnce, setOpenedOnce] = React.useState(expanded); React.useEffect(() => { if (showChildren) { @@ -92,14 +96,7 @@ function DocumentLink( } }, [showChildren]); - React.useEffect(() => { - if (expanded) { - setOpenedOnce(true); - } - }, [expanded]); - - // when the last child document is removed, - // also close the local folder state to closed + // when the last child document is removed auto-close the local folder state React.useEffect(() => { if (expanded && !hasChildDocuments) { setExpanded(false); @@ -116,7 +113,7 @@ function DocumentLink( ); const handleMouseEnter = React.useCallback(() => { - prefetchDocument(node.id); + prefetchDocument?.(node.id); }, [prefetchDocument, node]); const handleTitleChange = React.useCallback( @@ -190,7 +187,7 @@ function DocumentLink( !isDraft && !!pathToNode && !pathToNode.includes(monitor.getItem().id), - hover: (item, monitor) => { + hover: (_item, monitor) => { // Enables expansion of document children when hovering over the document // for more than half a second. if ( @@ -314,6 +311,7 @@ function DocumentLink( pathname: node.url, state: { title: node.title, + starred: inStarredSection, }, }} label={ @@ -325,14 +323,14 @@ function DocumentLink( maxLength={MAX_TITLE_LENGTH} /> } - isActive={(match, location) => - !!match && location.search !== "?starred" + isActive={(match, location: Location<{ starred?: boolean }>) => + !!match && location.state?.starred === inStarredSection } isActiveDrop={isOverReparent && canDropToReparent} depth={depth} exact={false} showActions={menuOpen} - scrollIntoViewIfNeeded={!document?.isStarred} + scrollIntoViewIfNeeded={!inStarredSection} isDraft={isDraft} ref={ref} menu={ @@ -375,41 +373,30 @@ function DocumentLink( /> )} - {openedOnce && ( - - {nodeChildren.map((childNode, index) => ( - - ))} - - )} + + {nodeChildren.map((childNode, index) => ( + + ))} + ); } -const Folder = styled.div<{ $open?: boolean }>` - display: ${(props) => (props.$open ? "block" : "none")}; -`; - -const Relative = styled.div` - position: relative; -`; - const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>` opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; pointer-events: ${(props) => (props.$isMoving ? "none" : "all")}; `; -const ObservedDocumentLink = observer(React.forwardRef(DocumentLink)); +const DocumentLink = observer(React.forwardRef(InnerDocumentLink)); -export default ObservedDocumentLink; +export default DocumentLink; diff --git a/app/components/Sidebar/components/DraggableCollectionLink.tsx b/app/components/Sidebar/components/DraggableCollectionLink.tsx new file mode 100644 index 000000000..00c50cb62 --- /dev/null +++ b/app/components/Sidebar/components/DraggableCollectionLink.tsx @@ -0,0 +1,148 @@ +import fractionalIndex from "fractional-index"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useDrop, useDrag, DropTargetMonitor } from "react-dnd"; +import { useLocation } from "react-router-dom"; +import styled from "styled-components"; +import Collection from "~/models/Collection"; +import Document from "~/models/Document"; +import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; +import CollectionLink from "./CollectionLink"; +import CollectionLinkChildren from "./CollectionLinkChildren"; +import DropCursor from "./DropCursor"; +import Relative from "./Relative"; +import { DragObject } from "./SidebarLink"; +import { useStarredContext } from "./StarredContext"; + +type Props = { + collection: Collection; + activeDocument: Document | undefined; + prefetchDocument: (id: string) => Promise; + belowCollection: Collection | void; +}; + +function useLocationStateStarred() { + const location = useLocation<{ + starred?: boolean; + }>(); + return location.state?.starred; +} + +function DraggableCollectionLink({ + collection, + activeDocument, + prefetchDocument, + belowCollection, +}: Props) { + const locationStateStarred = useLocationStateStarred(); + const { ui, collections } = useStores(); + const inStarredSection = useStarredContext(); + const [expanded, setExpanded] = React.useState( + collection.id === ui.activeCollectionId && + locationStateStarred === inStarredSection + ); + const can = usePolicy(collection.id); + const belowCollectionIndex = belowCollection ? belowCollection.index : null; + + // Drop to reorder collection + const [ + { isCollectionDropping, isDraggingAnyCollection }, + dropToReorderCollection, + ] = useDrop({ + accept: "collection", + drop: (item: DragObject) => { + collections.move( + item.id, + fractionalIndex(collection.index, belowCollectionIndex) + ); + }, + canDrop: (item) => { + return ( + collection.id !== item.id && + (!belowCollection || item.id !== belowCollection.id) + ); + }, + collect: (monitor: DropTargetMonitor) => ({ + isCollectionDropping: monitor.isOver(), + isDraggingAnyCollection: monitor.getItemType() === "collection", + }), + }); + + // Drag to reorder collection + const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({ + type: "collection", + item: () => { + return { + id: collection.id, + }; + }, + collect: (monitor) => ({ + isCollectionDragging: monitor.isDragging(), + }), + canDrag: () => { + return can.move; + }, + }); + + // If the current collection is active and relevant to the sidebar section we + // are in then expand it automatically + React.useEffect(() => { + if ( + collection.id === ui.activeCollectionId && + locationStateStarred === inStarredSection + ) { + setExpanded(true); + } + }, [ + collection.id, + ui.activeCollectionId, + locationStateStarred, + inStarredSection, + ]); + + const handleDisclosureClick = React.useCallback((ev) => { + ev.preventDefault(); + setExpanded((e) => !e); + }, []); + + const displayChildDocuments = expanded && !isCollectionDragging; + + return ( + <> + + + + + + {isDraggingAnyCollection && ( + + )} + + + ); +} + +const Draggable = styled("div")<{ $isDragging: boolean }>` + opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; + pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")}; +`; + +export default observer(DraggableCollectionLink); diff --git a/app/components/Sidebar/components/EmptyCollectionPlaceholder.tsx b/app/components/Sidebar/components/EmptyCollectionPlaceholder.tsx new file mode 100644 index 000000000..7e6bb281f --- /dev/null +++ b/app/components/Sidebar/components/EmptyCollectionPlaceholder.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Text from "~/components/Text"; + +const EmptyCollectionPlaceholder = () => { + const { t } = useTranslation(); + return ( + + {t("Empty")} + + ); +}; + +const Empty = styled(Text)` + margin-left: 46px; + margin-bottom: 0; + line-height: 34px; + font-style: italic; +`; + +export default EmptyCollectionPlaceholder; diff --git a/app/components/Sidebar/components/Folder.tsx b/app/components/Sidebar/components/Folder.tsx new file mode 100644 index 000000000..bb36ec6ab --- /dev/null +++ b/app/components/Sidebar/components/Folder.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import styled from "styled-components"; + +type Props = { + expanded: boolean; +}; + +const Folder: React.FC = ({ expanded, children }) => { + const [openedOnce, setOpenedOnce] = React.useState(expanded); + + // allows us to avoid rendering all children when the folder hasn't been opened + React.useEffect(() => { + if (expanded) { + setOpenedOnce(true); + } + }, [expanded]); + + if (!openedOnce) { + return null; + } + + return {children}; +}; + +const Wrapper = styled.div<{ $expanded?: boolean }>` + display: ${(props) => (props.$expanded ? "block" : "none")}; +`; + +export default Folder; diff --git a/app/components/Sidebar/components/Relative.tsx b/app/components/Sidebar/components/Relative.tsx new file mode 100644 index 000000000..ae17ec08a --- /dev/null +++ b/app/components/Sidebar/components/Relative.tsx @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +const Relative = styled.div` + position: relative; +`; + +export default Relative; diff --git a/app/components/Sidebar/components/Starred.tsx b/app/components/Sidebar/components/Starred.tsx index 10cbbaa5e..e9d84a80b 100644 --- a/app/components/Sidebar/components/Starred.tsx +++ b/app/components/Sidebar/components/Starred.tsx @@ -3,7 +3,6 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; -import styled from "styled-components"; import Star from "~/models/Star"; import Flex from "~/components/Flex"; import useStores from "~/hooks/useStores"; @@ -11,7 +10,9 @@ import useToasts from "~/hooks/useToasts"; import DropCursor from "./DropCursor"; import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; +import Relative from "./Relative"; import SidebarLink from "./SidebarLink"; +import StarredContext from "./StarredContext"; import StarredLink from "./StarredLink"; const STARRED_PAGINATION_LIMIT = 10; @@ -25,7 +26,7 @@ function Starred() { const [offset, setOffset] = React.useState(0); const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT); const { showToast } = useToasts(); - const { stars, documents } = useStores(); + const { stars } = useStores(); const { t } = useTranslation(); const fetchResults = React.useCallback(async () => { @@ -123,59 +124,45 @@ function Starred() { } return ( - -
- {t("Starred")} -
- {expanded && ( - - - {stars.orderedData.slice(0, upperBound).map((star) => { - const document = documents.get(star.documentId); - - return document ? ( - + +
+ {t("Starred")} +
+ {expanded && ( + + + {stars.orderedData.slice(0, upperBound).map((star) => ( + + ))} + {show === "More" && !isFetching && ( + - ) : null; - })} - {show === "More" && !isFetching && ( - - )} - {show === "Less" && !isFetching && ( - - )} - {(isFetching || fetchError) && !stars.orderedData.length && ( - - - - )} - - )} -
+ )} + {show === "Less" && !isFetching && ( + + )} + {(isFetching || fetchError) && !stars.orderedData.length && ( + + + + )} +
+ )} +
+ ); } -const Relative = styled.div` - position: relative; -`; - export default observer(Starred); diff --git a/app/components/Sidebar/components/StarredContext.ts b/app/components/Sidebar/components/StarredContext.ts new file mode 100644 index 000000000..44d9f2e0f --- /dev/null +++ b/app/components/Sidebar/components/StarredContext.ts @@ -0,0 +1,7 @@ +import * as React from "react"; + +const StarredContext = React.createContext(undefined); + +export const useStarredContext = () => React.useContext(StarredContext); + +export default StarredContext; diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index ebc022705..843672629 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -1,9 +1,11 @@ import fractionalIndex from "fractional-index"; +import { Location } from "history"; import { observer } from "mobx-react"; import { StarredIcon } from "outline-icons"; import * as React from "react"; import { useEffect, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; +import { useLocation } from "react-router-dom"; import styled, { useTheme } from "styled-components"; import parseTitle from "@shared/utils/parseTitle"; import Star from "~/models/Star"; @@ -12,46 +14,51 @@ import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; import useStores from "~/hooks/useStores"; import DocumentMenu from "~/menus/DocumentMenu"; +import CollectionLink from "./CollectionLink"; +import CollectionLinkChildren from "./CollectionLinkChildren"; +import DocumentLink from "./DocumentLink"; import DropCursor from "./DropCursor"; +import Folder from "./Folder"; +import Relative from "./Relative"; import SidebarLink from "./SidebarLink"; type Props = { - star?: Star; - depth: number; - title: string; - to: string; - documentId: string; - collectionId: string; + star: Star; }; -function StarredLink({ - depth, - to, - documentId, - title, - collectionId, - star, -}: Props) { +function useLocationStateStarred() { + const location = useLocation<{ + starred?: boolean; + }>(); + return location.state?.starred; +} + +function StarredLink({ star }: Props) { const theme = useTheme(); - const { collections, documents } = useStores(); - const collection = collections.get(collectionId); - const document = documents.get(documentId); - const [expanded, setExpanded] = useState(false); + const { ui, collections, documents } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); - const childDocuments = collection - ? collection.getDocumentChildren(documentId) - : []; - const hasChildDocuments = childDocuments.length > 0; + const { documentId, collectionId } = star; + const collection = collections.get(collectionId); + const locationStateStarred = useLocationStateStarred(); + const [expanded, setExpanded] = useState( + star.collectionId === ui.activeCollectionId && !!locationStateStarred + ); + + React.useEffect(() => { + if (star.collectionId === ui.activeCollectionId && locationStateStarred) { + setExpanded(true); + } + }, [star.collectionId, ui.activeCollectionId, locationStateStarred]); useEffect(() => { async function load() { - if (!document) { + if (documentId) { await documents.fetch(documentId); } } load(); - }, [collection, collectionId, collections, document, documentId, documents]); + }, [documentId, documents]); const handleDisclosureClick = React.useCallback( (ev: React.MouseEvent) => { @@ -69,9 +76,7 @@ function StarredLink({ collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), - canDrag: () => { - return depth === 0; - }, + canDrag: () => true, }); // Drop to reorder @@ -90,61 +95,109 @@ function StarredLink({ }), }); - const { emoji } = parseTitle(title); - const label = emoji ? title.replace(emoji, "") : title; + const displayChildDocuments = expanded && !isDragging; - return ( - <> - - 0; + + return ( + <> + + ) : ( ) - ) : undefined - } - isActive={(match, location) => - !!match && location.search === "?starred" - } - label={depth === 0 ? label : title} - exact={false} - showActions={menuOpen} - menu={ - document ? ( - - - - ) : undefined - } - /> - {isDraggingAny && ( - - )} - - {expanded && - childDocuments.map((childDocument) => ( - ) => + !!match && location.state?.starred === true + } + label={label} + exact={false} + showActions={menuOpen} + menu={ + document && !isDragging ? ( + + + + ) : undefined + } /> - ))} - - ); + + + + {childDocuments.map((node, index) => ( + + ))} + + {isDraggingAny && ( + + )} + + + ); + } + + if (collection) { + return ( + <> + + + + + + {isDraggingAny && ( + + )} + + + ); + } + + return null; } const Draggable = styled.div<{ $isDragging?: boolean }>` @@ -152,6 +205,4 @@ const Draggable = styled.div<{ $isDragging?: boolean }>` opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; `; -const ObserveredStarredLink = observer(StarredLink); - -export default ObserveredStarredLink; +export default observer(StarredLink); diff --git a/app/components/Sidebar/components/useCollectionDocuments.ts b/app/components/Sidebar/components/useCollectionDocuments.ts new file mode 100644 index 000000000..d3b7e7aa8 --- /dev/null +++ b/app/components/Sidebar/components/useCollectionDocuments.ts @@ -0,0 +1,39 @@ +import * as React from "react"; +import { sortNavigationNodes } from "@shared/utils/collections"; +import Collection from "~/models/Collection"; +import Document from "~/models/Document"; + +export default function useCollectionDocuments( + collection: Collection | undefined, + activeDocument: Document | undefined +) { + return React.useMemo(() => { + if (!collection) { + return []; + } + + if ( + activeDocument?.isActive && + activeDocument?.isDraft && + activeDocument?.collectionId === collection.id && + !activeDocument?.parentDocumentId + ) { + return sortNavigationNodes( + [activeDocument.asNavigationNode, ...collection.documents], + collection.sort + ); + } + + return collection.documents; + }, [ + activeDocument?.isActive, + activeDocument?.isDraft, + activeDocument?.collectionId, + activeDocument?.parentDocumentId, + activeDocument?.asNavigationNode, + collection, + collection?.documents, + collection?.id, + collection?.sort, + ]); +} diff --git a/app/components/Star.tsx b/app/components/Star.tsx index 4aa28e296..e379a9b2a 100644 --- a/app/components/Star.tsx +++ b/app/components/Star.tsx @@ -1,35 +1,56 @@ +import { observer } from "mobx-react"; import { StarredIcon, UnstarredIcon } from "outline-icons"; import * as React from "react"; import styled, { useTheme } from "styled-components"; +import Collection from "~/models/Collection"; import Document from "~/models/Document"; +import { + starCollection, + unstarCollection, +} from "~/actions/definitions/collections"; import { starDocument, unstarDocument } from "~/actions/definitions/documents"; import useActionContext from "~/hooks/useActionContext"; import { hover } from "~/styles"; import NudeButton from "./NudeButton"; type Props = { - document: Document; + collection?: Collection; + document?: Document; size?: number; }; -function Star({ size, document, ...rest }: Props) { +function Star({ size, document, collection, ...rest }: Props) { const theme = useTheme(); const context = useActionContext({ - activeDocumentId: document.id, + activeDocumentId: document?.id, + activeCollectionId: collection?.id, }); - if (!document) { + const target = document || collection; + + if (!target) { return null; } return ( - {document.isStarred ? ( + {target.isStarred ? ( ) : ( { + ev.preventDefault(); + ev.stopPropagation(); + collection.star(); + }, + [collection] + ); + + const handleUnstar = React.useCallback( + (ev: React.SyntheticEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + collection.unstar(); + }, + [collection] + ); + const alphabeticalSort = collection.sort.field === "title"; const can = usePolicy(collection.id); const canUserInTeam = usePolicy(team.id); const items: MenuItem[] = React.useMemo( () => [ + { + type: "button", + title: t("Unstar"), + onClick: handleUnstar, + visible: collection.isStarred && !!can.unstar, + icon: , + }, + { + type: "button", + title: t("Star"), + onClick: handleStar, + visible: !collection.isStarred && !!can.star, + icon: , + }, + { + type: "separator", + }, { type: "button", title: t("New document"), @@ -234,6 +271,10 @@ function CollectionMenu({ t, can.update, can.delete, + can.star, + can.unstar, + handleStar, + handleUnstar, alphabeticalSort, handleChangeSort, handleNewDocument, diff --git a/app/models/Collection.ts b/app/models/Collection.ts index 7761f1e24..a5a35a5af 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -1,5 +1,6 @@ import { trim } from "lodash"; import { action, computed, observable } from "mobx"; +import CollectionsStore from "~/stores/CollectionsStore"; import BaseModel from "~/models/BaseModel"; import Document from "~/models/Document"; import { NavigationNode } from "~/types"; @@ -7,6 +8,8 @@ import { client } from "~/utils/ApiClient"; import Field from "./decorators/Field"; export default class Collection extends BaseModel { + store: CollectionsStore; + @observable isSaving: boolean; @@ -91,6 +94,13 @@ export default class Collection extends BaseModel { return !!trim(this.description, "\\").trim(); } + @computed + get isStarred(): boolean { + return !!this.store.rootStore.stars.orderedData.find( + (star) => star.collectionId === this.id + ); + } + @action updateDocument(document: Document) { const travelNodes = (nodes: NavigationNode[]) => @@ -167,6 +177,16 @@ export default class Collection extends BaseModel { return path || []; } + @action + star = async () => { + return this.store.star(this); + }; + + @action + unstar = async () => { + return this.store.unstar(this); + }; + export = () => { return client.get("/collections.export", { id: this.id, diff --git a/app/models/Star.ts b/app/models/Star.ts index e8555f60a..cd87623a9 100644 --- a/app/models/Star.ts +++ b/app/models/Star.ts @@ -11,6 +11,8 @@ class Star extends BaseModel { documentId: string; + collectionId: string; + createdAt: string; updatedAt: string; diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index afadcba38..dad856073 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -23,6 +23,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PinnedDocuments from "~/components/PinnedDocuments"; import PlaceholderText from "~/components/PlaceholderText"; import Scene from "~/components/Scene"; +import Star, { AnimatedStar } from "~/components/Star"; import Tab from "~/components/Tab"; import Tabs from "~/components/Tabs"; import Tooltip from "~/components/Tooltip"; @@ -127,7 +128,7 @@ function CollectionScene() { ) : ( <> - + {collection.name} {!collection.permission && ( @@ -140,6 +141,7 @@ function CollectionScene() { {t("Private")} )} + @@ -247,10 +249,37 @@ function CollectionScene() { ); } -const HeadingWithIcon = styled(Heading)` +const StarButton = styled(Star)` + position: relative; + top: 0; + left: 10px; + overflow: hidden; + width: 24px; + + svg { + position: relative; + left: -4px; + } +`; + +const HeadingWithIcon = styled(Heading)<{ $isStarred: boolean }>` display: flex; align-items: center; + ${AnimatedStar} { + opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)}; + } + + &:hover { + ${AnimatedStar} { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + } + ${breakpoint("tablet")` margin-left: -40px; `}; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index cbf943cb9..a14231ad2 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -397,6 +397,7 @@ class DocumentScene extends React.Component { }; onChangeTitle = action((value: string) => { + this.title = value; this.props.document.title = value; this.updateIsDirty(); this.autosave(); @@ -448,7 +449,12 @@ class DocumentScene extends React.Component { return ( {this.props.location.pathname !== canonicalUrl && ( - + )} diff --git a/app/scenes/Document/components/EditableTitle.tsx b/app/scenes/Document/components/EditableTitle.tsx index 122c6c81e..70349b228 100644 --- a/app/scenes/Document/components/EditableTitle.tsx +++ b/app/scenes/Document/components/EditableTitle.tsx @@ -13,7 +13,6 @@ import Document from "~/models/Document"; import ContentEditable, { RefHandle } from "~/components/ContentEditable"; import Star, { AnimatedStar } from "~/components/Star"; import useEmojiWidth from "~/hooks/useEmojiWidth"; -import usePolicy from "~/hooks/usePolicy"; import { isModKey } from "~/utils/keyboard"; type Props = { @@ -49,7 +48,6 @@ const EditableTitle = React.forwardRef( }: Props, ref: React.RefObject ) => { - const can = usePolicy(document.id); const normalizedTitle = !value && readOnly ? document.titleWithDefault : value; @@ -135,9 +133,7 @@ const EditableTitle = React.forwardRef( dir="auto" ref={ref} > - {(can.star || can.unstar) && starrable !== false && ( - - )} + {starrable !== false && } ); } diff --git a/app/scenes/DocumentNew.tsx b/app/scenes/DocumentNew.tsx index 4e6764f0c..5b1100f1f 100644 --- a/app/scenes/DocumentNew.tsx +++ b/app/scenes/DocumentNew.tsx @@ -34,7 +34,7 @@ function DocumentNew() { title: "", text: "", }); - history.replace(editDocumentUrl(document)); + history.replace(editDocumentUrl(document), location.state); } catch (err) { showToast(t("Couldn’t create the document, try again?"), { type: "error", diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts index 03d92ecd2..de52b5e5c 100644 --- a/app/stores/CollectionsStore.ts +++ b/app/stores/CollectionsStore.ts @@ -174,6 +174,19 @@ export default class CollectionsStore extends BaseStore { ); } + star = async (collection: Collection) => { + await this.rootStore.stars.create({ + collectionId: collection.id, + }); + }; + + unstar = async (collection: Collection) => { + const star = this.rootStore.stars.orderedData.find( + (star) => star.collectionId === collection.id + ); + await star?.delete(); + }; + getPathForDocument(documentId: string): DocumentPath | undefined { return this.pathsToDocuments.find((path) => path.id === documentId); } diff --git a/app/types.ts b/app/types.ts index 2a7dccc2a..bd985b225 100644 --- a/app/types.ts +++ b/app/types.ts @@ -70,6 +70,7 @@ export type ActionContext = { isContextMenu: boolean; isCommandBar: boolean; isButton: boolean; + inStarredSection?: boolean; activeCollectionId: string | undefined; activeDocumentId: string | undefined; currentUserId: string | undefined; diff --git a/server/commands/starCreator.test.ts b/server/commands/starCreator.test.ts index 09f563671..47f87a736 100644 --- a/server/commands/starCreator.test.ts +++ b/server/commands/starCreator.test.ts @@ -1,3 +1,4 @@ +import { sequelize } from "@server/database/sequelize"; import { Star, Event } from "@server/models"; import { buildDocument, buildUser } from "@server/test/factories"; import { flushdb } from "@server/test/support"; @@ -14,11 +15,14 @@ describe("starCreator", () => { teamId: user.teamId, }); - const star = await starCreator({ - documentId: document.id, - user, - ip, - }); + const star = await sequelize.transaction(async (transaction) => + starCreator({ + documentId: document.id, + user, + ip, + transaction, + }) + ); const event = await Event.findOne(); expect(star.documentId).toEqual(document.id); @@ -43,11 +47,14 @@ describe("starCreator", () => { index: "P", }); - const star = await starCreator({ - documentId: document.id, - user, - ip, - }); + const star = await sequelize.transaction(async (transaction) => + starCreator({ + documentId: document.id, + user, + ip, + transaction, + }) + ); const events = await Event.count(); expect(star.documentId).toEqual(document.id); diff --git a/server/commands/starCreator.ts b/server/commands/starCreator.ts index f809cc184..02a55bae9 100644 --- a/server/commands/starCreator.ts +++ b/server/commands/starCreator.ts @@ -1,17 +1,19 @@ import fractionalIndex from "fractional-index"; -import { Sequelize, WhereOptions } from "sequelize"; -import { sequelize } from "@server/database/sequelize"; +import { Sequelize, Transaction, WhereOptions } from "sequelize"; import { Star, User, Event } from "@server/models"; type Props = { /** The user creating the star */ user: User; /** The document to star */ - documentId: string; + documentId?: string; + /** The collection to star */ + collectionId?: string; /** The sorted index for the star in the sidebar If no index is provided then it will be at the end */ index?: string; /** The IP address of the user creating the star */ ip: string; + transaction: Transaction; }; /** @@ -24,7 +26,9 @@ type Props = { export default async function starCreator({ user, documentId, + collectionId, ip, + transaction, ...rest }: Props): Promise { let { index } = rest; @@ -43,46 +47,43 @@ export default async function starCreator({ Sequelize.literal('"star"."index" collate "C"'), ["updatedAt", "DESC"], ], + transaction, }); // create a star at the beginning of the list index = fractionalIndex(null, stars.length ? stars[0].index : null); } - const transaction = await sequelize.transaction(); - let star; - - try { - const response = await Star.findOrCreate({ - where: { - userId: user.id, - documentId, - }, - defaults: { - index, - }, - transaction, - }); - star = response[0]; - - if (response[1]) { - await Event.create( - { - name: "stars.create", - modelId: star.id, + const response = await Star.findOrCreate({ + where: documentId + ? { userId: user.id, - actorId: user.id, documentId, - ip, + } + : { + userId: user.id, + collectionId, }, - { transaction } - ); - } + defaults: { + index, + }, + transaction, + }); + const star = response[0]; - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; + if (response[1]) { + await Event.create( + { + name: "stars.create", + modelId: star.id, + userId: user.id, + actorId: user.id, + documentId, + collectionId, + ip, + }, + { transaction } + ); } return star; diff --git a/server/migrations/20220402032204-starred-collections.js b/server/migrations/20220402032204-starred-collections.js new file mode 100644 index 000000000..31bab6e4e --- /dev/null +++ b/server/migrations/20220402032204-starred-collections.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("stars", "collectionId", { + type: Sequelize.UUID, + allowNull: true, + references: { + model: "collections", + }, + }); + await queryInterface.changeColumn("stars", "documentId", { + type: Sequelize.UUID, + allowNull: true + }); + await queryInterface.changeColumn("stars", "documentId", { + type: Sequelize.UUID, + references: { + model: "documents", + }, + }); + await queryInterface.changeColumn("stars", "userId", { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users", + }, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("stars", "collectionId"); + } +}; diff --git a/server/models/Star.ts b/server/models/Star.ts index 0ede31dbc..07f9449f3 100644 --- a/server/models/Star.ts +++ b/server/models/Star.ts @@ -5,6 +5,7 @@ import { ForeignKey, Table, } from "sequelize-typescript"; +import Collection from "./Collection"; import Document from "./Document"; import User from "./User"; import BaseModel from "./base/BaseModel"; @@ -26,11 +27,18 @@ class Star extends BaseModel { userId: string; @BelongsTo(() => Document, "documentId") - document: Document; + document: Document | null; @ForeignKey(() => Document) @Column(DataType.UUID) - documentId: string; + documentId: string | null; + + @BelongsTo(() => Collection, "collectionId") + collection: Collection | null; + + @ForeignKey(() => Collection) + @Column(DataType.UUID) + collectionId: string | null; } export default Star; diff --git a/server/policies/collection.ts b/server/policies/collection.ts index 7d96c56e6..7a3cb0145 100644 --- a/server/policies/collection.ts +++ b/server/policies/collection.ts @@ -39,7 +39,7 @@ allow(User, "move", Collection, (user, collection) => { throw AdminRequiredError(); }); -allow(User, "read", Collection, (user, collection) => { +allow(User, ["read", "star", "unstar"], Collection, (user, collection) => { if (!collection || user.teamId !== collection.teamId) { return false; } diff --git a/server/presenters/star.ts b/server/presenters/star.ts index 0795d26c5..27b263a8e 100644 --- a/server/presenters/star.ts +++ b/server/presenters/star.ts @@ -4,6 +4,7 @@ export default function present(star: Star) { return { id: star.id, documentId: star.documentId, + collectionId: star.collectionId, index: star.index, createdAt: star.createdAt, updatedAt: star.updatedAt, diff --git a/server/routes/api/__snapshots__/documents.test.ts.snap b/server/routes/api/__snapshots__/documents.test.ts.snap index a52eb069b..69d711f51 100644 --- a/server/routes/api/__snapshots__/documents.test.ts.snap +++ b/server/routes/api/__snapshots__/documents.test.ts.snap @@ -53,15 +53,6 @@ Object { } `; -exports[`#documents.starred should require authentication 1`] = ` -Object { - "error": "authentication_required", - "message": "Authentication required", - "ok": false, - "status": 401, -} -`; - exports[`#documents.unstar should require authentication 1`] = ` Object { "error": "authentication_required", diff --git a/server/routes/api/documents.test.ts b/server/routes/api/documents.test.ts index f6edc5d99..de4b2ab02 100644 --- a/server/routes/api/documents.test.ts +++ b/server/routes/api/documents.test.ts @@ -1455,45 +1455,6 @@ describe("#documents.viewed", () => { }); }); -describe("#documents.starred", () => { - it("should return empty result if no stars", async () => { - const { user } = await seed(); - const res = await server.post("/api/documents.starred", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.length).toEqual(0); - }); - - it("should return starred documents", async () => { - const { user, document } = await seed(); - await Star.create({ - documentId: document.id, - userId: user.id, - }); - const res = await server.post("/api/documents.starred", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.length).toEqual(1); - expect(body.data[0].id).toEqual(document.id); - expect(body.policies[0].abilities.update).toEqual(true); - }); - - it("should require authentication", async () => { - const res = await server.post("/api/documents.starred"); - const body = await res.json(); - expect(res.status).toEqual(401); - expect(body).toMatchSnapshot(); - }); -}); - describe("#documents.move", () => { it("should move the document", async () => { const { user, document } = await seed(); diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts index 3f6e019ea..e381cc581 100644 --- a/server/routes/api/documents.ts +++ b/server/routes/api/documents.ts @@ -313,62 +313,6 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => { }; }); -// Deprecated – use stars.list instead -router.post("documents.starred", auth(), pagination(), async (ctx) => { - let { direction } = ctx.body; - const { sort = "updatedAt" } = ctx.body; - - assertSort(sort, Document); - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - const collectionIds = await user.collectionIds(); - const stars = await Star.findAll({ - where: { - userId: user.id, - }, - order: [[sort, direction]], - include: [ - { - model: Document, - where: { - collectionId: collectionIds, - }, - include: [ - { - model: Collection.scope({ - method: ["withMembership", user.id], - }), - as: "collection", - }, - { - model: Star, - as: "starred", - where: { - userId: user.id, - }, - }, - ], - }, - ], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - - const documents = stars.map((star) => star.document); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); - router.post("documents.drafts", auth(), pagination(), async (ctx) => { let { direction } = ctx.body; const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body; diff --git a/server/routes/api/stars.ts b/server/routes/api/stars.ts index f8159ee33..8e0a0f34f 100644 --- a/server/routes/api/stars.ts +++ b/server/routes/api/stars.ts @@ -3,8 +3,9 @@ import { Sequelize } from "sequelize"; import starCreator from "@server/commands/starCreator"; import starDestroyer from "@server/commands/starDestroyer"; import starUpdater from "@server/commands/starUpdater"; +import { sequelize } from "@server/database/sequelize"; import auth from "@server/middlewares/authentication"; -import { Document, Star } from "@server/models"; +import { Document, Star, Collection } from "@server/models"; import { authorize } from "@server/policies"; import { presentStar, @@ -18,27 +19,43 @@ import pagination from "./middlewares/pagination"; const router = new Router(); router.post("stars.create", auth(), async (ctx) => { - const { documentId } = ctx.body; + const { documentId, collectionId } = ctx.body; const { index } = ctx.body; - assertUuid(documentId, "documentId is required"); - const { user } = ctx.state; - const document = await Document.findByPk(documentId, { - userId: user.id, - }); - authorize(user, "star", document); + + assertUuid( + documentId || collectionId, + "documentId or collectionId is required" + ); + + if (documentId) { + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "star", document); + } + + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "star", collection); + } if (index) { assertIndexCharacters(index); } - const star = await starCreator({ - user, - documentId, - ip: ctx.request.ip, - index, - }); - + const star = await sequelize.transaction(async (transaction) => + starCreator({ + user, + documentId, + collectionId, + ip: ctx.request.ip, + index, + transaction, + }) + ); ctx.body = { data: presentStar(star), policies: presentPolicies(user, [star]), @@ -72,12 +89,17 @@ router.post("stars.list", auth(), pagination(), async (ctx) => { }); } - const documents = await Document.defaultScopeWithUser(user.id).findAll({ - where: { - id: stars.map((star) => star.documentId), - collectionId: collectionIds, - }, - }); + const documentIds = stars + .map((star) => star.documentId) + .filter(Boolean) as string[]; + const documents = documentIds.length + ? await Document.defaultScopeWithUser(user.id).findAll({ + where: { + id: documentIds, + collectionId: collectionIds, + }, + }) + : []; const policies = presentPolicies(user, [...documents, ...stars]); diff --git a/server/utils/indexing.ts b/server/utils/indexing.ts index 6bbe8bbb8..e64030a3c 100644 --- a/server/utils/indexing.ts +++ b/server/utils/indexing.ts @@ -48,7 +48,7 @@ export async function starIndexing( const documents = await Document.findAll({ attributes: ["id", "updatedAt"], where: { - id: stars.map((star) => star.documentId), + id: stars.map((star) => star.documentId).filter(Boolean) as string[], }, order: [["updatedAt", "DESC"]], }); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b475b98b2..a248f7af1 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -3,13 +3,13 @@ "New collection": "New collection", "Create a collection": "Create a collection", "Edit collection": "Edit collection", + "Star": "Star", + "Unstar": "Unstar", "Delete IndexedDB cache": "Delete IndexedDB cache", "IndexedDB cache deleted": "IndexedDB cache deleted", "Development": "Development", "Open document": "Open document", "New document": "New document", - "Star": "Star", - "Unstar": "Unstar", "Download": "Download", "Download document": "Download document", "Duplicate": "Duplicate", @@ -138,6 +138,7 @@ "Documents": "Documents", "Logo": "Logo", "Document archived": "Document archived", + "Empty": "Empty", "Move document": "Move document", "Collections": "Collections", "You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",