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 (
-
-
- {expanded && (
-
-
- {stars.orderedData.slice(0, upperBound).map((star) => {
- const document = documents.get(star.documentId);
-
- return document ? (
-
+
+
+ {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",