Skip to content

Commit

Permalink
feat: Moving documents via drag and drop in sidebar (outline#1717)
Browse files Browse the repository at this point in the history
* wip: added some basic drag and drop UI for combining items

* refactor: pathToDocument to accept only id

* fix: Multiple drop backends error
fix: Incorrect styling dragging over active collection
fix: Stay in disabled state until save is complete

* Improving display while moving doc

* fix: update by user should be changed when moving a doc

* add move guard to drag

Co-authored-by: Tom Moor <[email protected]>
  • Loading branch information
thenanyu and tommoor authored Dec 16, 2020
1 parent 3469b82 commit 051ecab
Show file tree
Hide file tree
Showing 12 changed files with 286 additions and 167 deletions.
2 changes: 1 addition & 1 deletion app/components/Breadcrumb.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
}

const path = collection.pathToDocument
? collection.pathToDocument(document).slice(0, -1)
? collection.pathToDocument(document.id).slice(0, -1)
: [];

if (onlyText === true) {
Expand Down
95 changes: 57 additions & 38 deletions app/components/Sidebar/components/CollectionLink.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Document from "models/Document";
Expand All @@ -10,6 +10,7 @@ import DropToImport from "components/DropToImport";
import DocumentLink from "./DocumentLink";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";

type Props = {|
Expand All @@ -20,27 +21,44 @@ type Props = {|
prefetchDocument: (id: string) => Promise<void>,
|};

@observer
class CollectionLink extends React.Component<Props> {
@observable menuOpen = false;
function CollectionLink({
collection,
activeDocument,
prefetchDocument,
canUpdate,
ui,
}: Props) {
const [menuOpen, setMenuOpen] = React.useState(false);

handleTitleChange = async (name: string) => {
await this.props.collection.save({ name });
};
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({ name });
},
[collection]
);

render() {
const {
collection,
activeDocument,
prefetchDocument,
canUpdate,
ui,
} = this.props;
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;

const expanded = collection.id === ui.activeCollectionId;
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
if (!collection) return;
documents.move(item.id, collection.id);
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});

return (
<>
return (
<>
<div ref={drop}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLink
key={collection.id}
Expand All @@ -50,11 +68,12 @@ class CollectionLink extends React.Component<Props> {
}
iconColor={collection.color}
expanded={expanded}
menuOpen={this.menuOpen}
menuOpen={menuOpen}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={this.handleTitleChange}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
}
Expand All @@ -63,28 +82,28 @@ class CollectionLink extends React.Component<Props> {
<CollectionMenu
position="right"
collection={collection}
onOpen={() => (this.menuOpen = true)}
onClose={() => (this.menuOpen = false)}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
}
></SidebarLink>
</DropToImport>
</div>

{expanded &&
collection.documents.map((node) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
/>
))}
</>
);
}
{expanded &&
collection.documents.map((node) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
/>
))}
</>
);
}

export default CollectionLink;
export default observer(CollectionLink);
141 changes: 95 additions & 46 deletions app/components/Sidebar/components/DocumentLink.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
Expand Down Expand Up @@ -33,7 +34,7 @@ function DocumentLink({
depth,
canUpdate,
}: Props) {
const { documents } = useStores();
const { documents, policies } = useStores();
const { t } = useTranslation();

const isActiveDocument = activeDocument && activeDocument.id === node.id;
Expand All @@ -48,13 +49,19 @@ function DocumentLink({
}
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);

const pathToNode = React.useMemo(
() =>
collection && collection.pathToDocument(node.id).map((entry) => entry.id),
[collection, node]
);

const showChildren = React.useMemo(() => {
return !!(
hasChildDocuments &&
activeDocument &&
collection &&
(collection
.pathToDocument(activeDocument)
.pathToDocument(activeDocument.id)
.map((entry) => entry.id)
.includes(node.id) ||
isActiveDocument)
Expand Down Expand Up @@ -100,51 +107,88 @@ function DocumentLink({
);

const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;

// Draggable
const [{ isDragging }, drag] = useDrag({
item: { type: "document", ...node, depth, active: isActiveDocument },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return policies.abilities(node.id).move;
},
});

// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
documents.move(item.id, collection.id, node.id);
},
canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id),
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});

return (
<React.Fragment key={node.id}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
></SidebarLink>
</DropToImport>

{expanded && (
<>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={drop}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
isActiveDrop={isOver && canDrop}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>

{expanded && !isDragging && (
<>
{node.children.map((childNode) => (
<ObservedDocumentLink
Expand All @@ -159,10 +203,15 @@ function DocumentLink({
))}
</>
)}
</React.Fragment>
</>
);
}

const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;

const Disclosure = styled(CollapsedIcon)`
position: absolute;
left: -24px;
Expand Down
17 changes: 14 additions & 3 deletions app/components/Sidebar/components/SidebarLink.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Props = {
menuOpen?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
theme: Theme,
exact?: boolean,
depth?: number,
Expand All @@ -30,6 +31,7 @@ function SidebarLink({
to,
label,
active,
isActiveDrop,
menu,
menuOpen,
theme,
Expand All @@ -54,7 +56,8 @@ function SidebarLink({

return (
<StyledNavLink
activeStyle={activeStyle}
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? undefined : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
Expand Down Expand Up @@ -103,12 +106,20 @@ const StyledNavLink = styled(NavLink)`
text-overflow: ellipsis;
padding: 4px 16px;
border-radius: 4px;
color: ${(props) => props.theme.sidebarText};
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 15px;
cursor: pointer;

svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
}

&:hover {
color: ${(props) => props.theme.text};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}

&:focus {
Expand Down
Loading

0 comments on commit 051ecab

Please sign in to comment.