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,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<Document | void>;
belowCollection: Collection | void;
expanded?: boolean;
onDisclosureClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
};
function CollectionLink({
const CollectionLink: React.FC<Props> = ({
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: (
<DocumentReparent
item={item}
collection={collection}
onSubmit={dialogs.closeAllModals}
onCancel={dialogs.closeAllModals}
/>
),
});
} 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<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 handleTitleEditing = React.useCallback((isEditing: boolean) => {
setIsEditing(isEditing);
}, []);
const context = useActionContext({
activeCollectionId: collection.id,
inStarredSection,
});
return (
<>
<Relative ref={drop}>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
$isDragging={isCollectionDragging}
$isMoving={isCollectionDragging}
>
<DropToImport collectionId={collection.id}>
<SidebarLink
to={collection.url}
expanded={displayDocumentLinks}
onDisclosureClick={(event) => {
event.preventDefault();
setExpanded((prev) => !prev);
}}
icon={
<CollectionIcon
collection={collection}
expanded={displayDocumentLinks}
/>
}
showActions={menuOpen}
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}
<DropToImport collectionId={collection.id}>
<SidebarLink
to={{
pathname: collection.url,
state: { starred: inStarredSection },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === inStarredSection
}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
)}
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
}
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>
</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);

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 { 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) => (
<CollectionLink
<DraggableCollectionLink
key={collection.id}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
belowCollection={orderedCollections[index + 1]}
/>
))}
@@ -116,8 +115,4 @@ function Collections() {
);
}
const Relative = styled.div`
position: relative;
`;
export default observer(Collections);

View File

@@ -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<Document | void>;
prefetchDocument?: (documentId: string) => Promise<Document | void>;
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<DragObject>().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(
/>
)}
</Relative>
{openedOnce && (
<Folder $open={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</Folder>
)}
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
<DocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
</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 }>`
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;

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 { 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 (
<Flex column>
<Header onClick={handleExpandClick} expanded={expanded}>
{t("Starred")}
</Header>
{expanded && (
<Relative>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{stars.orderedData.slice(0, upperBound).map((star) => {
const document = documents.get(star.documentId);
return document ? (
<StarredLink
key={star.id}
star={star}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
<StarredContext.Provider value={true}>
<Flex column>
<Header onClick={handleExpandClick} expanded={expanded}>
{t("Starred")}
</Header>
{expanded && (
<Relative>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{stars.orderedData.slice(0, upperBound).map((star) => (
<StarredLink key={star.id} star={star} />
))}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={0}
/>
) : null;
})}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={0}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={0}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</Relative>
)}
</Flex>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={0}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</Relative>
)}
</Flex>
</StarredContext.Provider>
);
}
const Relative = styled.div`
position: relative;
`;
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 { 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<HTMLButtonElement>) => {
@@ -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 (
<>
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
<SidebarLink
depth={depth}
expanded={hasChildDocuments ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
to={`${to}?starred`}
icon={
depth === 0 ? (
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
const collection = collections.get(document.collectionId);
const { emoji } = parseTitle(document.title);
const label = emoji
? 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 ? (
<EmojiIcon emoji={emoji} />
) : (
<StarredIcon color={theme.yellow} />
)
) : undefined
}
isActive={(match, location) =>
!!match && location.search === "?starred"
}
label={depth === 0 ? label : title}
exact={false}
showActions={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : 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}
}
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === true
}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
))}
</>
);
</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 }>`
@@ -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);

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 * 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 (
<NudeButton
context={context}
action={document.isStarred ? unstarDocument : starDocument}
hideOnActionDisabled
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{document.isStarred ? (
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
@@ -58,4 +79,4 @@ export const AnimatedStar = styled(StarredIcon)`
}
`;
export default Star;
export default observer(Star);