feat: Add ability to star collection (#3327)

* Migrations, models, commands

* ui

* Move starred hint to location state

* lint

* tsc

* refactor

* Add collection empty state in expanded sidebar

* Add empty placeholder within starred collections

* Drag and drop improves, Relative refactor

* fix: Starring untitled draft leaves empty space

* fix: Creating draft in starred collection shouldnt open main

* fix: Dupe drop cursor

* Final fixes

* fix: Canonical redirect replaces starred location state

* fix: Don't show reorder cursor at the top of collection with no permission to edit when dragging
This commit is contained in:
Tom Moor
2022-04-03 18:51:01 -07:00
committed by GitHub
parent 3de06b8005
commit 84d6bf8ddf
36 changed files with 988 additions and 635 deletions

View File

@@ -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 * as React from "react";
import stores from "~/stores"; import stores from "~/stores";
import Collection from "~/models/Collection"; 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: <StarredIcon />,
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: <UnstarredIcon />,
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,
];

View File

@@ -52,8 +52,11 @@ export const createDocument = createAction({
visible: ({ activeCollectionId, stores }) => visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId && !!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update, stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId }) => perform: ({ activeCollectionId, inStarredSection }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)), activeCollectionId &&
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
}); });
export const starDocument = createAction({ export const starDocument = createAction({

View File

@@ -1,18 +1,15 @@
import fractionalIndex from "fractional-index"; import { Location } from "history";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd"; import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import styled from "styled-components";
import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent"; import DocumentReparent from "~/scenes/DocumentReparent";
import CollectionIcon from "~/components/CollectionIcon"; import CollectionIcon from "~/components/CollectionIcon";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import Modal from "~/components/Modal";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import { createDocument } from "~/actions/definitions/documents"; import { createDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext"; import useActionContext from "~/hooks/useActionContext";
@@ -21,40 +18,36 @@ import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu"; import CollectionMenu from "~/menus/CollectionMenu";
import { NavigationNode } from "~/types"; import { NavigationNode } from "~/types";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle"; import EditableTitle from "./EditableTitle";
import Relative from "./Relative";
import SidebarLink, { DragObject } from "./SidebarLink"; import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = { type Props = {
collection: Collection; collection: Collection;
canUpdate: boolean; expanded?: boolean;
activeDocument: Document | null | undefined; onDisclosureClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
prefetchDocument: (id: string) => Promise<Document | void>; activeDocument: Document | undefined;
belowCollection: Collection | void; isDraggingAnyCollection?: boolean;
}; };
function CollectionLink({ const CollectionLink: React.FC<Props> = ({
collection, collection,
activeDocument, expanded,
prefetchDocument, onDisclosureClick,
canUpdate, isDraggingAnyCollection,
belowCollection, }) => {
}: Props) {
const history = useHistory();
const { t } = useTranslation();
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [
permissionOpen,
handlePermissionOpen,
handlePermissionClose,
] = useBoolean();
const itemRef = React.useRef< const itemRef = React.useRef<
NavigationNode & { depth: number; active: boolean; collectionId: string } NavigationNode & { depth: number; active: boolean; collectionId: string }
>(); >();
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false); 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( const handleTitleChange = React.useCallback(
async (name: string) => { async (name: string) => {
@@ -66,26 +59,6 @@ function CollectionLink({
[collection, history] [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 // Drop to re-parent document
const [{ isOver, canDrop }, drop] = useDrop({ const [{ isOver, canDrop }, drop] = useDrop({
accept: "document", accept: "document",
@@ -111,14 +84,23 @@ function CollectionLink({
prevCollection.permission !== collection.permission prevCollection.permission !== collection.permission
) { ) {
itemRef.current = item; itemRef.current = item;
handlePermissionOpen();
dialogs.openModal({
title: t("Move document"),
content: (
<DocumentReparent
item={item}
collection={collection}
onSubmit={dialogs.closeAllModals}
onCancel={dialogs.closeAllModals}
/>
),
});
} else { } else {
documents.move(id, collection.id); documents.move(id, collection.id);
} }
}, },
canDrop: () => { canDrop: () => canUpdate,
return can.update;
},
collect: (monitor) => ({ collect: (monitor) => ({
isOver: !!monitor.isOver({ isOver: !!monitor.isOver({
shallow: true, shallow: true,
@@ -127,224 +109,69 @@ function CollectionLink({
}), }),
}); });
// Drop to reorder document const handleTitleEditing = React.useCallback((isEditing: boolean) => {
const [{ isOverReorder }, dropToReorder] = useDrop({ setIsEditing(isEditing);
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<Collection, Collection>) => ({
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 context = useActionContext({ const context = useActionContext({
activeCollectionId: collection.id, activeCollectionId: collection.id,
inStarredSection,
}); });
return ( return (
<> <>
<Relative ref={drop}> <Relative ref={drop}>
<Draggable <DropToImport collectionId={collection.id}>
key={collection.id} <SidebarLink
ref={dragToReorderCollection} to={{
$isDragging={isCollectionDragging} pathname: collection.url,
$isMoving={isCollectionDragging} state: { starred: inStarredSection },
> }}
<DropToImport collectionId={collection.id}> expanded={expanded}
<SidebarLink onDisclosureClick={onDisclosureClick}
to={collection.url} icon={
expanded={displayDocumentLinks} <CollectionIcon collection={collection} expanded={expanded} />
onDisclosureClick={(event) => { }
event.preventDefault(); showActions={menuOpen}
setExpanded((prev) => !prev); isActiveDrop={isOver && canDrop}
}} isActive={(match, location: Location<{ starred?: boolean }>) =>
icon={ !!match && location.state?.starred === inStarredSection
<CollectionIcon }
collection={collection} label={
expanded={displayDocumentLinks} <EditableTitle
/> title={collection.name}
} onSubmit={handleTitleChange}
showActions={menuOpen} onEditing={handleTitleEditing}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
/>
}
exact={false}
depth={0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ tooltip: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Draggable>
</Relative>
<Relative>
{openedOnce && (
<Folder $open={displayDocumentLinks}>
{manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
{collectionDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate} canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2}
index={index}
/> />
))} }
</Folder> exact={false}
)} depth={0}
{isDraggingAnyCollection && ( menu={
<DropCursor !isEditing &&
isActiveDrop={isCollectionDropping} !isDraggingAnyCollection && (
innerRef={dropToReorderCollection} <Fade>
<NudeButton
tooltip={{ tooltip: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/> />
)} </DropToImport>
</Relative> </Relative>
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
{itemRef.current && (
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
)}
</Modal>
</> </>
); );
} };
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); export default observer(CollectionLink);

View File

@@ -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<Document | void>;
};
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 (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.update && (
<DropCursor
disabled={!manualSort}
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
{childDocuments.length === 0 && <EmptyCollectionPlaceholder />}
</Folder>
);
}
export default observer(CollectionLinkChildren);

View File

@@ -3,24 +3,24 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useDrop } from "react-dnd"; import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import { createCollection } from "~/actions/definitions/collections"; import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
import CollectionLink from "./CollectionLink"; import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import Header from "./Header"; import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections"; import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction"; import SidebarAction from "./SidebarAction";
import { DragObject } from "./SidebarLink"; import { DragObject } from "./SidebarLink";
function Collections() { function Collections() {
const [isFetching, setFetching] = React.useState(false); const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState(); const [fetchError, setFetchError] = React.useState();
const { policies, documents, collections } = useStores(); const { documents, collections } = useStores();
const { showToast } = useToasts(); const { showToast } = useToasts();
const [expanded, setExpanded] = React.useState(true); const [expanded, setExpanded] = React.useState(true);
const isPreloaded = !!collections.orderedData.length; const isPreloaded = !!collections.orderedData.length;
@@ -82,12 +82,11 @@ function Collections() {
/> />
)} )}
{orderedCollections.map((collection: Collection, index: number) => ( {orderedCollections.map((collection: Collection, index: number) => (
<CollectionLink <DraggableCollectionLink
key={collection.id} key={collection.id}
collection={collection} collection={collection}
activeDocument={documents.active} activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument} prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
belowCollection={orderedCollections[index + 1]} belowCollection={orderedCollections[index + 1]}
/> />
))} ))}
@@ -116,8 +115,4 @@ function Collections() {
); );
} }
const Relative = styled.div`
position: relative;
`;
export default observer(Collections); export default observer(Collections);

View File

@@ -1,3 +1,4 @@
import { Location } from "history";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
@@ -13,6 +14,7 @@ import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
@@ -21,24 +23,25 @@ import { newDocumentPath } from "~/utils/routeHelpers";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle"; import EditableTitle from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarLink, { DragObject } from "./SidebarLink"; import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = { type Props = {
node: NavigationNode; node: NavigationNode;
canUpdate: boolean;
collection?: Collection; collection?: Collection;
activeDocument: Document | null | undefined; activeDocument: Document | null | undefined;
prefetchDocument: (documentId: string) => Promise<Document | void>; prefetchDocument?: (documentId: string) => Promise<Document | void>;
isDraft?: boolean; isDraft?: boolean;
depth: number; depth: number;
index: number; index: number;
parentId?: string; parentId?: string;
}; };
function DocumentLink( function InnerDocumentLink(
{ {
node, node,
canUpdate,
collection, collection,
activeDocument, activeDocument,
prefetchDocument, prefetchDocument,
@@ -52,12 +55,14 @@ function DocumentLink(
const { showToast } = useToasts(); const { showToast } = useToasts();
const { documents, policies } = useStores(); const { documents, policies } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const canUpdate = usePolicy(node.id).update;
const isActiveDocument = activeDocument && activeDocument.id === node.id; const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments = const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id; !!node.children.length || activeDocument?.parentDocumentId === node.id;
const document = documents.get(node.id); const document = documents.get(node.id);
const { fetchChildDocuments } = documents; const { fetchChildDocuments } = documents;
const [isEditing, setIsEditing] = React.useState(false); const [isEditing, setIsEditing] = React.useState(false);
const inStarredSection = useStarredContext();
React.useEffect(() => { React.useEffect(() => {
if (isActiveDocument && hasChildDocuments) { if (isActiveDocument && hasChildDocuments) {
@@ -84,7 +89,6 @@ function DocumentLink(
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]); }, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
const [expanded, setExpanded] = React.useState(showChildren); const [expanded, setExpanded] = React.useState(showChildren);
const [openedOnce, setOpenedOnce] = React.useState(expanded);
React.useEffect(() => { React.useEffect(() => {
if (showChildren) { if (showChildren) {
@@ -92,14 +96,7 @@ function DocumentLink(
} }
}, [showChildren]); }, [showChildren]);
React.useEffect(() => { // when the last child document is removed auto-close the local folder state
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
// when the last child document is removed,
// also close the local folder state to closed
React.useEffect(() => { React.useEffect(() => {
if (expanded && !hasChildDocuments) { if (expanded && !hasChildDocuments) {
setExpanded(false); setExpanded(false);
@@ -116,7 +113,7 @@ function DocumentLink(
); );
const handleMouseEnter = React.useCallback(() => { const handleMouseEnter = React.useCallback(() => {
prefetchDocument(node.id); prefetchDocument?.(node.id);
}, [prefetchDocument, node]); }, [prefetchDocument, node]);
const handleTitleChange = React.useCallback( const handleTitleChange = React.useCallback(
@@ -190,7 +187,7 @@ function DocumentLink(
!isDraft && !isDraft &&
!!pathToNode && !!pathToNode &&
!pathToNode.includes(monitor.getItem<DragObject>().id), !pathToNode.includes(monitor.getItem<DragObject>().id),
hover: (item, monitor) => { hover: (_item, monitor) => {
// Enables expansion of document children when hovering over the document // Enables expansion of document children when hovering over the document
// for more than half a second. // for more than half a second.
if ( if (
@@ -314,6 +311,7 @@ function DocumentLink(
pathname: node.url, pathname: node.url,
state: { state: {
title: node.title, title: node.title,
starred: inStarredSection,
}, },
}} }}
label={ label={
@@ -325,14 +323,14 @@ function DocumentLink(
maxLength={MAX_TITLE_LENGTH} maxLength={MAX_TITLE_LENGTH}
/> />
} }
isActive={(match, location) => isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.search !== "?starred" !!match && location.state?.starred === inStarredSection
} }
isActiveDrop={isOverReparent && canDropToReparent} isActiveDrop={isOverReparent && canDropToReparent}
depth={depth} depth={depth}
exact={false} exact={false}
showActions={menuOpen} showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred} scrollIntoViewIfNeeded={!inStarredSection}
isDraft={isDraft} isDraft={isDraft}
ref={ref} ref={ref}
menu={ menu={
@@ -375,41 +373,30 @@ function DocumentLink(
/> />
)} )}
</Relative> </Relative>
{openedOnce && ( <Folder expanded={expanded && !isDragging}>
<Folder $open={expanded && !isDragging}> {nodeChildren.map((childNode, index) => (
{nodeChildren.map((childNode, index) => ( <DocumentLink
<ObservedDocumentLink key={childNode.id}
key={childNode.id} collection={collection}
collection={collection} node={childNode}
node={childNode} activeDocument={activeDocument}
activeDocument={activeDocument} prefetchDocument={prefetchDocument}
prefetchDocument={prefetchDocument} isDraft={childNode.isDraft}
isDraft={childNode.isDraft} depth={depth + 1}
depth={depth + 1} index={index}
canUpdate={canUpdate} parentId={node.id}
index={index} />
parentId={node.id} ))}
/> </Folder>
))}
</Folder>
)}
</> </>
); );
} }
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 }>` const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")}; 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;

View File

@@ -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<Document | void>;
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<Collection, Collection>) => ({
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 (
<>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
$isDragging={isCollectionDragging}
>
<CollectionLink
collection={collection}
expanded={displayChildDocuments}
activeDocument={activeDocument}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={isDraggingAnyCollection}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
prefetchDocument={prefetchDocument}
/>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
/>
)}
</Relative>
</>
);
}
const Draggable = styled("div")<{ $isDragging: boolean }>`
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")};
`;
export default observer(DraggableCollectionLink);

View File

@@ -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 (
<Empty type="tertiary" size="small">
{t("Empty")}
</Empty>
);
};
const Empty = styled(Text)`
margin-left: 46px;
margin-bottom: 0;
line-height: 34px;
font-style: italic;
`;
export default EmptyCollectionPlaceholder;

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
expanded: boolean;
};
const Folder: React.FC<Props> = ({ 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 <Wrapper $expanded={expanded}>{children}</Wrapper>;
};
const Wrapper = styled.div<{ $expanded?: boolean }>`
display: ${(props) => (props.$expanded ? "block" : "none")};
`;
export default Folder;

View File

@@ -0,0 +1,7 @@
import styled from "styled-components";
const Relative = styled.div`
position: relative;
`;
export default Relative;

View File

@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useDrop } from "react-dnd"; import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Star from "~/models/Star"; import Star from "~/models/Star";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
@@ -11,7 +10,9 @@ import useToasts from "~/hooks/useToasts";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import Header from "./Header"; import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections"; import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink"; import SidebarLink from "./SidebarLink";
import StarredContext from "./StarredContext";
import StarredLink from "./StarredLink"; import StarredLink from "./StarredLink";
const STARRED_PAGINATION_LIMIT = 10; const STARRED_PAGINATION_LIMIT = 10;
@@ -25,7 +26,7 @@ function Starred() {
const [offset, setOffset] = React.useState(0); const [offset, setOffset] = React.useState(0);
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT); const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
const { showToast } = useToasts(); const { showToast } = useToasts();
const { stars, documents } = useStores(); const { stars } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const fetchResults = React.useCallback(async () => { const fetchResults = React.useCallback(async () => {
@@ -123,59 +124,45 @@ function Starred() {
} }
return ( return (
<Flex column> <StarredContext.Provider value={true}>
<Header onClick={handleExpandClick} expanded={expanded}> <Flex column>
{t("Starred")} <Header onClick={handleExpandClick} expanded={expanded}>
</Header> {t("Starred")}
{expanded && ( </Header>
<Relative> {expanded && (
<DropCursor <Relative>
isActiveDrop={isOverReorder} <DropCursor
innerRef={dropToReorder} isActiveDrop={isOverReorder}
position="top" innerRef={dropToReorder}
/> position="top"
{stars.orderedData.slice(0, upperBound).map((star) => { />
const document = documents.get(star.documentId); {stars.orderedData.slice(0, upperBound).map((star) => (
<StarredLink key={star.id} star={star} />
return document ? ( ))}
<StarredLink {show === "More" && !isFetching && (
key={star.id} <SidebarLink
star={star} onClick={handleShowMore}
documentId={document.id} label={`${t("Show more")}`}
collectionId={document.collectionId}
to={document.url}
title={document.title}
depth={0} depth={0}
/> />
) : null; )}
})} {show === "Less" && !isFetching && (
{show === "More" && !isFetching && ( <SidebarLink
<SidebarLink onClick={handleShowLess}
onClick={handleShowMore} label={`${t("Show less")}`}
label={`${t("Show more")}`} depth={0}
depth={0} />
/> )}
)} {(isFetching || fetchError) && !stars.orderedData.length && (
{show === "Less" && !isFetching && ( <Flex column>
<SidebarLink <PlaceholderCollections />
onClick={handleShowLess} </Flex>
label={`${t("Show less")}`} )}
depth={0} </Relative>
/> )}
)} </Flex>
{(isFetching || fetchError) && !stars.orderedData.length && ( </StarredContext.Provider>
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</Relative>
)}
</Flex>
); );
} }
const Relative = styled.div`
position: relative;
`;
export default observer(Starred); export default observer(Starred);

View File

@@ -0,0 +1,7 @@
import * as React from "react";
const StarredContext = React.createContext<boolean | undefined>(undefined);
export const useStarredContext = () => React.useContext(StarredContext);
export default StarredContext;

View File

@@ -1,9 +1,11 @@
import fractionalIndex from "fractional-index"; import fractionalIndex from "fractional-index";
import { Location } from "history";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { StarredIcon } from "outline-icons"; import { StarredIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDrag, useDrop } from "react-dnd"; import { useDrag, useDrop } from "react-dnd";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import parseTitle from "@shared/utils/parseTitle"; import parseTitle from "@shared/utils/parseTitle";
import Star from "~/models/Star"; import Star from "~/models/Star";
@@ -12,46 +14,51 @@ import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink"; import SidebarLink from "./SidebarLink";
type Props = { type Props = {
star?: Star; star: Star;
depth: number;
title: string;
to: string;
documentId: string;
collectionId: string;
}; };
function StarredLink({ function useLocationStateStarred() {
depth, const location = useLocation<{
to, starred?: boolean;
documentId, }>();
title, return location.state?.starred;
collectionId, }
star,
}: Props) { function StarredLink({ star }: Props) {
const theme = useTheme(); const theme = useTheme();
const { collections, documents } = useStores(); const { ui, collections, documents } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const childDocuments = collection const { documentId, collectionId } = star;
? collection.getDocumentChildren(documentId) const collection = collections.get(collectionId);
: []; const locationStateStarred = useLocationStateStarred();
const hasChildDocuments = childDocuments.length > 0; 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(() => { useEffect(() => {
async function load() { async function load() {
if (!document) { if (documentId) {
await documents.fetch(documentId); await documents.fetch(documentId);
} }
} }
load(); load();
}, [collection, collectionId, collections, document, documentId, documents]); }, [documentId, documents]);
const handleDisclosureClick = React.useCallback( const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => { (ev: React.MouseEvent<HTMLButtonElement>) => {
@@ -69,9 +76,7 @@ function StarredLink({
collect: (monitor) => ({ collect: (monitor) => ({
isDragging: !!monitor.isDragging(), isDragging: !!monitor.isDragging(),
}), }),
canDrag: () => { canDrag: () => true,
return depth === 0;
},
}); });
// Drop to reorder // Drop to reorder
@@ -90,61 +95,109 @@ function StarredLink({
}), }),
}); });
const { emoji } = parseTitle(title); const displayChildDocuments = expanded && !isDragging;
const label = emoji ? title.replace(emoji, "") : title;
return ( if (documentId) {
<> const document = documents.get(documentId);
<Draggable key={documentId} ref={drag} $isDragging={isDragging}> if (!document) {
<SidebarLink return null;
depth={depth} }
expanded={hasChildDocuments ? expanded : undefined}
onDisclosureClick={handleDisclosureClick} const collection = collections.get(document.collectionId);
to={`${to}?starred`} const { emoji } = parseTitle(document.title);
icon={ const label = emoji
depth === 0 ? ( ? document.title.replace(emoji, "")
: document.titleWithDefault;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
return (
<>
<Draggable key={star.id} ref={drag} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { starred: true },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={
emoji ? ( emoji ? (
<EmojiIcon emoji={emoji} /> <EmojiIcon emoji={emoji} />
) : ( ) : (
<StarredIcon color={theme.yellow} /> <StarredIcon color={theme.yellow} />
) )
) : undefined }
} isActive={(match, location: Location<{ starred?: boolean }>) =>
isActive={(match, location) => !!match && location.state?.starred === true
!!match && location.search === "?starred" }
} label={label}
label={depth === 0 ? label : title} exact={false}
exact={false} showActions={menuOpen}
showActions={menuOpen} menu={
menu={ document && !isDragging ? (
document ? ( <Fade>
<Fade> <DocumentMenu
<DocumentMenu document={document}
document={document} onOpen={handleMenuOpen}
onOpen={handleMenuOpen} onClose={handleMenuClose}
onClose={handleMenuClose} />
/> </Fade>
</Fade> ) : undefined
) : undefined }
}
/>
{isDraggingAny && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Draggable>
{expanded &&
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth === 0 ? 2 : depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
collectionId={collectionId}
/> />
))} </Draggable>
</> <Relative>
); <Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{isDraggingAny && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
</>
);
}
if (collection) {
return (
<>
<Draggable key={star?.id} ref={drag} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={isDraggingAny}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{isDraggingAny && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
</>
);
}
return null;
} }
const Draggable = styled.div<{ $isDragging?: boolean }>` const Draggable = styled.div<{ $isDragging?: boolean }>`
@@ -152,6 +205,4 @@ const Draggable = styled.div<{ $isDragging?: boolean }>`
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
`; `;
const ObserveredStarredLink = observer(StarredLink); export default observer(StarredLink);
export default ObserveredStarredLink;

View File

@@ -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,
]);
}

View File

@@ -1,35 +1,56 @@
import { observer } from "mobx-react";
import { StarredIcon, UnstarredIcon } from "outline-icons"; import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import {
starCollection,
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents"; import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext"; import useActionContext from "~/hooks/useActionContext";
import { hover } from "~/styles"; import { hover } from "~/styles";
import NudeButton from "./NudeButton"; import NudeButton from "./NudeButton";
type Props = { type Props = {
document: Document; collection?: Collection;
document?: Document;
size?: number; size?: number;
}; };
function Star({ size, document, ...rest }: Props) { function Star({ size, document, collection, ...rest }: Props) {
const theme = useTheme(); const theme = useTheme();
const context = useActionContext({ const context = useActionContext({
activeDocumentId: document.id, activeDocumentId: document?.id,
activeCollectionId: collection?.id,
}); });
if (!document) { const target = document || collection;
if (!target) {
return null; return null;
} }
return ( return (
<NudeButton <NudeButton
context={context} context={context}
action={document.isStarred ? unstarDocument : starDocument} hideOnActionDisabled
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size} size={size}
{...rest} {...rest}
> >
{document.isStarred ? ( {target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} /> <AnimatedStar size={size} color={theme.yellow} />
) : ( ) : (
<AnimatedStar <AnimatedStar
@@ -58,4 +79,4 @@ export const AnimatedStar = styled(StarredIcon)`
} }
`; `;
export default Star; export default observer(Star);

View File

@@ -8,6 +8,8 @@ import {
PadlockIcon, PadlockIcon,
AlphabeticalSortIcon, AlphabeticalSortIcon,
ManualSortIcon, ManualSortIcon,
UnstarredIcon,
StarredIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -151,11 +153,46 @@ function CollectionMenu({
}); });
}, [dialogs, t, collection]); }, [dialogs, t, collection]);
const handleStar = React.useCallback(
(ev: React.SyntheticEvent) => {
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 alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection.id); const can = usePolicy(collection.id);
const canUserInTeam = usePolicy(team.id); const canUserInTeam = usePolicy(team.id);
const items: MenuItem[] = React.useMemo( const items: MenuItem[] = React.useMemo(
() => [ () => [
{
type: "button",
title: t("Unstar"),
onClick: handleUnstar,
visible: collection.isStarred && !!can.unstar,
icon: <UnstarredIcon />,
},
{
type: "button",
title: t("Star"),
onClick: handleStar,
visible: !collection.isStarred && !!can.star,
icon: <StarredIcon />,
},
{
type: "separator",
},
{ {
type: "button", type: "button",
title: t("New document"), title: t("New document"),
@@ -234,6 +271,10 @@ function CollectionMenu({
t, t,
can.update, can.update,
can.delete, can.delete,
can.star,
can.unstar,
handleStar,
handleUnstar,
alphabeticalSort, alphabeticalSort,
handleChangeSort, handleChangeSort,
handleNewDocument, handleNewDocument,

View File

@@ -1,5 +1,6 @@
import { trim } from "lodash"; import { trim } from "lodash";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import CollectionsStore from "~/stores/CollectionsStore";
import BaseModel from "~/models/BaseModel"; import BaseModel from "~/models/BaseModel";
import Document from "~/models/Document"; import Document from "~/models/Document";
import { NavigationNode } from "~/types"; import { NavigationNode } from "~/types";
@@ -7,6 +8,8 @@ import { client } from "~/utils/ApiClient";
import Field from "./decorators/Field"; import Field from "./decorators/Field";
export default class Collection extends BaseModel { export default class Collection extends BaseModel {
store: CollectionsStore;
@observable @observable
isSaving: boolean; isSaving: boolean;
@@ -91,6 +94,13 @@ export default class Collection extends BaseModel {
return !!trim(this.description, "\\").trim(); return !!trim(this.description, "\\").trim();
} }
@computed
get isStarred(): boolean {
return !!this.store.rootStore.stars.orderedData.find(
(star) => star.collectionId === this.id
);
}
@action @action
updateDocument(document: Document) { updateDocument(document: Document) {
const travelNodes = (nodes: NavigationNode[]) => const travelNodes = (nodes: NavigationNode[]) =>
@@ -167,6 +177,16 @@ export default class Collection extends BaseModel {
return path || []; return path || [];
} }
@action
star = async () => {
return this.store.star(this);
};
@action
unstar = async () => {
return this.store.unstar(this);
};
export = () => { export = () => {
return client.get("/collections.export", { return client.get("/collections.export", {
id: this.id, id: this.id,

View File

@@ -11,6 +11,8 @@ class Star extends BaseModel {
documentId: string; documentId: string;
collectionId: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -23,6 +23,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments"; import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Star, { AnimatedStar } from "~/components/Star";
import Tab from "~/components/Tab"; import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs"; import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
@@ -127,7 +128,7 @@ function CollectionScene() {
<Empty collection={collection} /> <Empty collection={collection} />
) : ( ) : (
<> <>
<HeadingWithIcon> <HeadingWithIcon $isStarred={collection.isStarred}>
<HeadingIcon collection={collection} size={40} expanded /> <HeadingIcon collection={collection} size={40} expanded />
{collection.name} {collection.name}
{!collection.permission && ( {!collection.permission && (
@@ -140,6 +141,7 @@ function CollectionScene() {
<Badge>{t("Private")}</Badge> <Badge>{t("Private")}</Badge>
</Tooltip> </Tooltip>
)} )}
<StarButton collection={collection} size={32} />
</HeadingWithIcon> </HeadingWithIcon>
<CollectionDescription collection={collection} /> <CollectionDescription collection={collection} />
@@ -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; display: flex;
align-items: center; align-items: center;
${AnimatedStar} {
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
}
&:hover {
${AnimatedStar} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
${breakpoint("tablet")` ${breakpoint("tablet")`
margin-left: -40px; margin-left: -40px;
`}; `};

View File

@@ -397,6 +397,7 @@ class DocumentScene extends React.Component<Props> {
}; };
onChangeTitle = action((value: string) => { onChangeTitle = action((value: string) => {
this.title = value;
this.props.document.title = value; this.props.document.title = value;
this.updateIsDirty(); this.updateIsDirty();
this.autosave(); this.autosave();
@@ -448,7 +449,12 @@ class DocumentScene extends React.Component<Props> {
return ( return (
<ErrorBoundary> <ErrorBoundary>
{this.props.location.pathname !== canonicalUrl && ( {this.props.location.pathname !== canonicalUrl && (
<Redirect to={canonicalUrl} /> <Redirect
to={{
pathname: canonicalUrl,
state: this.props.location.state,
}}
/>
)} )}
<RegisterKeyDown trigger="m" handler={this.goToMove} /> <RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} /> <RegisterKeyDown trigger="e" handler={this.goToEdit} />

View File

@@ -13,7 +13,6 @@ import Document from "~/models/Document";
import ContentEditable, { RefHandle } from "~/components/ContentEditable"; import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import Star, { AnimatedStar } from "~/components/Star"; import Star, { AnimatedStar } from "~/components/Star";
import useEmojiWidth from "~/hooks/useEmojiWidth"; import useEmojiWidth from "~/hooks/useEmojiWidth";
import usePolicy from "~/hooks/usePolicy";
import { isModKey } from "~/utils/keyboard"; import { isModKey } from "~/utils/keyboard";
type Props = { type Props = {
@@ -49,7 +48,6 @@ const EditableTitle = React.forwardRef(
}: Props, }: Props,
ref: React.RefObject<RefHandle> ref: React.RefObject<RefHandle>
) => { ) => {
const can = usePolicy(document.id);
const normalizedTitle = const normalizedTitle =
!value && readOnly ? document.titleWithDefault : value; !value && readOnly ? document.titleWithDefault : value;
@@ -135,9 +133,7 @@ const EditableTitle = React.forwardRef(
dir="auto" dir="auto"
ref={ref} ref={ref}
> >
{(can.star || can.unstar) && starrable !== false && ( {starrable !== false && <StarButton document={document} size={32} />}
<StarButton document={document} size={32} />
)}
</Title> </Title>
); );
} }

View File

@@ -34,7 +34,7 @@ function DocumentNew() {
title: "", title: "",
text: "", text: "",
}); });
history.replace(editDocumentUrl(document)); history.replace(editDocumentUrl(document), location.state);
} catch (err) { } catch (err) {
showToast(t("Couldnt create the document, try again?"), { showToast(t("Couldnt create the document, try again?"), {
type: "error", type: "error",

View File

@@ -174,6 +174,19 @@ export default class CollectionsStore extends BaseStore<Collection> {
); );
} }
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 { getPathForDocument(documentId: string): DocumentPath | undefined {
return this.pathsToDocuments.find((path) => path.id === documentId); return this.pathsToDocuments.find((path) => path.id === documentId);
} }

View File

@@ -70,6 +70,7 @@ export type ActionContext = {
isContextMenu: boolean; isContextMenu: boolean;
isCommandBar: boolean; isCommandBar: boolean;
isButton: boolean; isButton: boolean;
inStarredSection?: boolean;
activeCollectionId: string | undefined; activeCollectionId: string | undefined;
activeDocumentId: string | undefined; activeDocumentId: string | undefined;
currentUserId: string | undefined; currentUserId: string | undefined;

View File

@@ -1,3 +1,4 @@
import { sequelize } from "@server/database/sequelize";
import { Star, Event } from "@server/models"; import { Star, Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories"; import { buildDocument, buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support"; import { flushdb } from "@server/test/support";
@@ -14,11 +15,14 @@ describe("starCreator", () => {
teamId: user.teamId, teamId: user.teamId,
}); });
const star = await starCreator({ const star = await sequelize.transaction(async (transaction) =>
documentId: document.id, starCreator({
user, documentId: document.id,
ip, user,
}); ip,
transaction,
})
);
const event = await Event.findOne(); const event = await Event.findOne();
expect(star.documentId).toEqual(document.id); expect(star.documentId).toEqual(document.id);
@@ -43,11 +47,14 @@ describe("starCreator", () => {
index: "P", index: "P",
}); });
const star = await starCreator({ const star = await sequelize.transaction(async (transaction) =>
documentId: document.id, starCreator({
user, documentId: document.id,
ip, user,
}); ip,
transaction,
})
);
const events = await Event.count(); const events = await Event.count();
expect(star.documentId).toEqual(document.id); expect(star.documentId).toEqual(document.id);

View File

@@ -1,17 +1,19 @@
import fractionalIndex from "fractional-index"; import fractionalIndex from "fractional-index";
import { Sequelize, WhereOptions } from "sequelize"; import { Sequelize, Transaction, WhereOptions } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import { Star, User, Event } from "@server/models"; import { Star, User, Event } from "@server/models";
type Props = { type Props = {
/** The user creating the star */ /** The user creating the star */
user: User; user: User;
/** The document to star */ /** 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 */ /** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
index?: string; index?: string;
/** The IP address of the user creating the star */ /** The IP address of the user creating the star */
ip: string; ip: string;
transaction: Transaction;
}; };
/** /**
@@ -24,7 +26,9 @@ type Props = {
export default async function starCreator({ export default async function starCreator({
user, user,
documentId, documentId,
collectionId,
ip, ip,
transaction,
...rest ...rest
}: Props): Promise<Star> { }: Props): Promise<Star> {
let { index } = rest; let { index } = rest;
@@ -43,46 +47,43 @@ export default async function starCreator({
Sequelize.literal('"star"."index" collate "C"'), Sequelize.literal('"star"."index" collate "C"'),
["updatedAt", "DESC"], ["updatedAt", "DESC"],
], ],
transaction,
}); });
// create a star at the beginning of the list // create a star at the beginning of the list
index = fractionalIndex(null, stars.length ? stars[0].index : null); index = fractionalIndex(null, stars.length ? stars[0].index : null);
} }
const transaction = await sequelize.transaction(); const response = await Star.findOrCreate({
let star; where: documentId
? {
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,
userId: user.id, userId: user.id,
actorId: user.id,
documentId, documentId,
ip, }
: {
userId: user.id,
collectionId,
}, },
{ transaction } defaults: {
); index,
} },
transaction,
});
const star = response[0];
await transaction.commit(); if (response[1]) {
} catch (err) { await Event.create(
await transaction.rollback(); {
throw err; name: "stars.create",
modelId: star.id,
userId: user.id,
actorId: user.id,
documentId,
collectionId,
ip,
},
{ transaction }
);
} }
return star; return star;

View File

@@ -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");
}
};

View File

@@ -5,6 +5,7 @@ import {
ForeignKey, ForeignKey,
Table, Table,
} from "sequelize-typescript"; } from "sequelize-typescript";
import Collection from "./Collection";
import Document from "./Document"; import Document from "./Document";
import User from "./User"; import User from "./User";
import BaseModel from "./base/BaseModel"; import BaseModel from "./base/BaseModel";
@@ -26,11 +27,18 @@ class Star extends BaseModel {
userId: string; userId: string;
@BelongsTo(() => Document, "documentId") @BelongsTo(() => Document, "documentId")
document: Document; document: Document | null;
@ForeignKey(() => Document) @ForeignKey(() => Document)
@Column(DataType.UUID) @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; export default Star;

View File

@@ -39,7 +39,7 @@ allow(User, "move", Collection, (user, collection) => {
throw AdminRequiredError(); throw AdminRequiredError();
}); });
allow(User, "read", Collection, (user, collection) => { allow(User, ["read", "star", "unstar"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) { if (!collection || user.teamId !== collection.teamId) {
return false; return false;
} }

View File

@@ -4,6 +4,7 @@ export default function present(star: Star) {
return { return {
id: star.id, id: star.id,
documentId: star.documentId, documentId: star.documentId,
collectionId: star.collectionId,
index: star.index, index: star.index,
createdAt: star.createdAt, createdAt: star.createdAt,
updatedAt: star.updatedAt, updatedAt: star.updatedAt,

View File

@@ -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`] = ` exports[`#documents.unstar should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",

View File

@@ -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", () => { describe("#documents.move", () => {
it("should move the document", async () => { it("should move the document", async () => {
const { user, document } = await seed(); const { user, document } = await seed();

View File

@@ -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) => { router.post("documents.drafts", auth(), pagination(), async (ctx) => {
let { direction } = ctx.body; let { direction } = ctx.body;
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body; const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body;

View File

@@ -3,8 +3,9 @@ import { Sequelize } from "sequelize";
import starCreator from "@server/commands/starCreator"; import starCreator from "@server/commands/starCreator";
import starDestroyer from "@server/commands/starDestroyer"; import starDestroyer from "@server/commands/starDestroyer";
import starUpdater from "@server/commands/starUpdater"; import starUpdater from "@server/commands/starUpdater";
import { sequelize } from "@server/database/sequelize";
import auth from "@server/middlewares/authentication"; 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 { authorize } from "@server/policies";
import { import {
presentStar, presentStar,
@@ -18,27 +19,43 @@ import pagination from "./middlewares/pagination";
const router = new Router(); const router = new Router();
router.post("stars.create", auth(), async (ctx) => { router.post("stars.create", auth(), async (ctx) => {
const { documentId } = ctx.body; const { documentId, collectionId } = ctx.body;
const { index } = ctx.body; const { index } = ctx.body;
assertUuid(documentId, "documentId is required");
const { user } = ctx.state; const { user } = ctx.state;
const document = await Document.findByPk(documentId, {
userId: user.id, assertUuid(
}); documentId || collectionId,
authorize(user, "star", document); "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) { if (index) {
assertIndexCharacters(index); assertIndexCharacters(index);
} }
const star = await starCreator({ const star = await sequelize.transaction(async (transaction) =>
user, starCreator({
documentId, user,
ip: ctx.request.ip, documentId,
index, collectionId,
}); ip: ctx.request.ip,
index,
transaction,
})
);
ctx.body = { ctx.body = {
data: presentStar(star), data: presentStar(star),
policies: presentPolicies(user, [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({ const documentIds = stars
where: { .map((star) => star.documentId)
id: stars.map((star) => star.documentId), .filter(Boolean) as string[];
collectionId: collectionIds, const documents = documentIds.length
}, ? await Document.defaultScopeWithUser(user.id).findAll({
}); where: {
id: documentIds,
collectionId: collectionIds,
},
})
: [];
const policies = presentPolicies(user, [...documents, ...stars]); const policies = presentPolicies(user, [...documents, ...stars]);

View File

@@ -48,7 +48,7 @@ export async function starIndexing(
const documents = await Document.findAll({ const documents = await Document.findAll({
attributes: ["id", "updatedAt"], attributes: ["id", "updatedAt"],
where: { where: {
id: stars.map((star) => star.documentId), id: stars.map((star) => star.documentId).filter(Boolean) as string[],
}, },
order: [["updatedAt", "DESC"]], order: [["updatedAt", "DESC"]],
}); });

View File

@@ -3,13 +3,13 @@
"New collection": "New collection", "New collection": "New collection",
"Create a collection": "Create a collection", "Create a collection": "Create a collection",
"Edit collection": "Edit collection", "Edit collection": "Edit collection",
"Star": "Star",
"Unstar": "Unstar",
"Delete IndexedDB cache": "Delete IndexedDB cache", "Delete IndexedDB cache": "Delete IndexedDB cache",
"IndexedDB cache deleted": "IndexedDB cache deleted", "IndexedDB cache deleted": "IndexedDB cache deleted",
"Development": "Development", "Development": "Development",
"Open document": "Open document", "Open document": "Open document",
"New document": "New document", "New document": "New document",
"Star": "Star",
"Unstar": "Unstar",
"Download": "Download", "Download": "Download",
"Download document": "Download document", "Download document": "Download document",
"Duplicate": "Duplicate", "Duplicate": "Duplicate",
@@ -138,6 +138,7 @@
"Documents": "Documents", "Documents": "Documents",
"Logo": "Logo", "Logo": "Logo",
"Document archived": "Document archived", "Document archived": "Document archived",
"Empty": "Empty",
"Move document": "Move document", "Move document": "Move document",
"Collections": "Collections", "Collections": "Collections",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection", "You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",