diff --git a/app/components/Breadcrumb.js b/app/components/Breadcrumb.js index a7502d2d..33f12809 100644 --- a/app/components/Breadcrumb.js +++ b/app/components/Breadcrumb.js @@ -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) { diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js index 1182500b..9be2a589 100644 --- a/app/components/Sidebar/components/CollectionLink.js +++ b/app/components/Sidebar/components/CollectionLink.js @@ -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"; @@ -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 = {| @@ -20,27 +21,44 @@ type Props = {| prefetchDocument: (id: string) => Promise, |}; -@observer -class CollectionLink extends React.Component { - @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 ( + <> +
{ } iconColor={collection.color} expanded={expanded} - menuOpen={this.menuOpen} + menuOpen={menuOpen} + isActiveDrop={isOver && canDrop} label={ } @@ -63,28 +82,28 @@ class CollectionLink extends React.Component { (this.menuOpen = true)} - onClose={() => (this.menuOpen = false)} + onOpen={() => setMenuOpen(true)} + onClose={() => setMenuOpen(false)} /> } > +
- {expanded && - collection.documents.map((node) => ( - - ))} - - ); - } + {expanded && + collection.documents.map((node) => ( + + ))} + + ); } -export default CollectionLink; +export default observer(CollectionLink); diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js index ce420847..bc6e80e7 100644 --- a/app/components/Sidebar/components/DocumentLink.js +++ b/app/components/Sidebar/components/DocumentLink.js @@ -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"; @@ -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; @@ -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) @@ -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 ( - - - - {hasChildDocuments && ( - - )} - - - } - depth={depth} - exact={false} - menuOpen={menuOpen} - menu={ - document ? ( - - setMenuOpen(true)} - onClose={() => setMenuOpen(false)} - /> - - ) : undefined - } - > - + <> + +
+ + + {hasChildDocuments && ( + + )} + + + } + isActiveDrop={isOver && canDrop} + depth={depth} + exact={false} + menuOpen={menuOpen} + menu={ + document && !isMoving ? ( + + setMenuOpen(true)} + onClose={() => setMenuOpen(false)} + /> + + ) : undefined + } + /> + +
+
- {expanded && ( + {expanded && !isDragging && ( <> {node.children.map((childNode) => ( )} -
+ ); } +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; diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js index f48c43de..f311b8fa 100644 --- a/app/components/Sidebar/components/SidebarLink.js +++ b/app/components/Sidebar/components/SidebarLink.js @@ -17,6 +17,7 @@ type Props = { menuOpen?: boolean, iconColor?: string, active?: boolean, + isActiveDrop?: boolean, theme: Theme, exact?: boolean, depth?: number, @@ -30,6 +31,7 @@ function SidebarLink({ to, label, active, + isActiveDrop, menu, menuOpen, theme, @@ -54,7 +56,8 @@ function SidebarLink({ return ( 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 { diff --git a/app/index.js b/app/index.js index 2098c7c0..4a6b63b9 100644 --- a/app/index.js +++ b/app/index.js @@ -3,9 +3,10 @@ import "mobx-react-lite/batchingForReactDom"; import "focus-visible"; import { Provider } from "mobx-react"; import * as React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { render } from "react-dom"; import { BrowserRouter as Router } from "react-router-dom"; - import { initI18n } from "shared/i18n"; import stores from "stores"; import ErrorBoundary from "components/ErrorBoundary"; @@ -24,14 +25,16 @@ if (element) { - - <> - - - - - - + + + <> + + + + + + + , diff --git a/app/models/Collection.js b/app/models/Collection.js index 5c96486a..0a6c0a2a 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -79,12 +79,12 @@ export default class Collection extends BaseModel { return result; } - pathToDocument(document: Document) { + pathToDocument(documentId: string) { let path; const traveler = (nodes, previousPath) => { nodes.forEach((childNode) => { const newPath = [...previousPath, childNode]; - if (childNode.id === document.id) { + if (childNode.id === documentId) { path = newPath; return; } diff --git a/app/models/Document.js b/app/models/Document.js index 4cc3278f..d20ffc0c 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -268,7 +268,7 @@ export default class Document extends BaseModel { }; move = (collectionId: string, parentDocumentId: ?string) => { - return this.store.move(this, collectionId, parentDocumentId); + return this.store.move(this.id, collectionId, parentDocumentId); }; duplicate = () => { diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 2f8d261d..4d4e77a0 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -19,6 +19,7 @@ export default class DocumentsStore extends BaseStore { @observable searchCache: Map = new Map(); @observable starredIds: Map = new Map(); @observable backlinks: Map = new Map(); + @observable movingDocumentId: ?string; importFileTypes: string[] = [ "text/markdown", @@ -450,20 +451,26 @@ export default class DocumentsStore extends BaseStore { @action move = async ( - document: Document, + documentId: string, collectionId: string, parentDocumentId: ?string ) => { - const res = await client.post("/documents.move", { - id: document.id, - collectionId, - parentDocumentId, - }); - invariant(res && res.data, "Data not available"); + this.movingDocumentId = documentId; - res.data.documents.forEach(this.add); - res.data.collections.forEach(this.rootStore.collections.add); - this.addPolicies(res.policies); + try { + const res = await client.post("/documents.move", { + id: documentId, + collectionId, + parentDocumentId, + }); + invariant(res && res.data, "Data not available"); + + res.data.documents.forEach(this.add); + res.data.collections.forEach(this.rootStore.collections.add); + this.addPolicies(res.policies); + } finally { + this.movingDocumentId = undefined; + } }; @action diff --git a/flow-typed/npm/react-dropzone_v4.x.x.js b/flow-typed/npm/react-dropzone_v4.x.x.js deleted file mode 100644 index 8d920dc9..00000000 --- a/flow-typed/npm/react-dropzone_v4.x.x.js +++ /dev/null @@ -1,56 +0,0 @@ -// flow-typed signature: c69369aa4bc769d5f1d4f6ec9c76d8f2 -// flow-typed version: c6154227d1/react-dropzone_v4.x.x/flow_>=v0.104.x - -declare module "react-dropzone" { - declare type ChildrenProps = { - draggedFiles: Array, - acceptedFiles: Array, - rejectedFiles: Array, - isDragActive: boolean, - isDragAccept: boolean, - isDragReject: boolean, - ... - } - - declare type DropzoneFile = File & { preview?: string, ... } - - declare type DropzoneProps = { - accept?: string, - children?: React$Node | (ChildrenProps) => React$Node, - disableClick?: boolean, - disabled?: boolean, - disablePreview?: boolean, - preventDropOnDocument?: boolean, - inputProps?: Object, - multiple?: boolean, - name?: string, - maxSize?: number, - minSize?: number, - className?: string, - activeClassName?: string, - acceptClassName?: string, - rejectClassName?: string, - disabledClassName?: string, - style?: Object, - activeStyle?: Object, - acceptStyle?: Object, - rejectStyle?: Object, - disabledStyle?: Object, - onClick?: (event: SyntheticMouseEvent<>) => mixed, - onDrop?: (acceptedFiles: Array, rejectedFiles: Array, event: SyntheticDragEvent<>) => mixed, - onDropAccepted?: (acceptedFiles: Array, event: SyntheticDragEvent<>) => mixed, - onDropRejected?: (rejectedFiles: Array, event: SyntheticDragEvent<>) => mixed, - onDragStart?: (event: SyntheticDragEvent<>) => mixed, - onDragEnter?: (event: SyntheticDragEvent<>) => mixed, - onDragOver?: (event: SyntheticDragEvent<>) => mixed, - onDragLeave?: (event: SyntheticDragEvent<>) => mixed, - onFileDialogCancel?: () => mixed, - ... - }; - - declare class Dropzone extends React$Component { - open(): void; - } - - declare module.exports: typeof Dropzone; -} diff --git a/package.json b/package.json index ef638ab1..e2ed4fe9 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,8 @@ "react-autosize-textarea": "^6.0.0", "react-avatar-editor": "^10.3.0", "react-color": "^2.17.3", + "react-dnd": "^11.1.3", + "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.8.6", "react-dropzone": "^11.2.4", "react-helmet": "^5.2.0", diff --git a/server/commands/documentMover.js b/server/commands/documentMover.js index 353bd455..2fa57012 100644 --- a/server/commands/documentMover.js +++ b/server/commands/documentMover.js @@ -28,6 +28,8 @@ export default async function documentMover({ document.collectionId = collectionId; document.parentDocumentId = null; + document.lastModifiedById = user.id; + document.updatedBy = user; await document.save(); result.documents.push(document); @@ -54,6 +56,8 @@ export default async function documentMover({ // add to new collection (may be the same) document.collectionId = collectionId; document.parentDocumentId = parentDocumentId; + document.lastModifiedById = user.id; + document.updatedBy = user; const newCollection: Collection = collectionChanged ? await Collection.findByPk(collectionId, { transaction }) diff --git a/yarn.lock b/yarn.lock index 89e8d467..e7797da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1365,6 +1365,21 @@ dependencies: "@types/node" ">= 8" +"@react-dnd/asap@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.0.tgz#b300eeed83e9801f51bd66b0337c9a6f04548651" + integrity sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ== + +"@react-dnd/invariant@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e" + integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw== + +"@react-dnd/shallowequal@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a" + integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg== + "@rehooks/window-scroll-position@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@rehooks/window-scroll-position/-/window-scroll-position-1.0.1.tgz#3cb80f22cbf9cdbd2041b5236ae1fce8245b2f1c" @@ -1555,6 +1570,14 @@ dependencies: "@types/unist" "*" +"@types/hoist-non-react-statics@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -1604,6 +1627,19 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.5.tgz#b6ab3bba29e16b821d84e09ecfaded462b816b00" integrity sha512-UEyp8LwZ4Dg30kVU2Q3amHHyTn1jEdhCIE59ANed76GaT1Vp76DD3ZWSAxgCrw6wJ0TqeoBpqmfUHiUDPs//HQ== +"@types/prop-types@*": + version "15.7.3" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" + integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + +"@types/react@*": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" + integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -3765,6 +3801,11 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.5.tgz#7fdec6a28a67ae18647c51668a9ff95bb2fa7bb8" + integrity sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -4002,6 +4043,15 @@ direction@^0.1.5: resolved "https://registry.yarnpkg.com/direction/-/direction-0.1.5.tgz#ce5d797f97e26f8be7beff53f7dc40e1c1a9ec4c" integrity sha1-zl15f5fib4vnvv9T99xA4cGp7Ew= +dnd-core@^11.1.3: + version "11.1.3" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-11.1.3.tgz#f92099ba7245e49729d2433157031a6267afcc98" + integrity sha512-QugF55dNW+h+vzxVJ/LSJeTeUw9MCJ2cllhmVThVPEtF16ooBkxj0WBE5RB+AceFxMFo1rO6bJKXtqKl+JNnyA== + dependencies: + "@react-dnd/asap" "^4.0.0" + "@react-dnd/invariant" "^2.0.0" + redux "^4.0.4" + doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -5753,7 +5803,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -9759,6 +9809,23 @@ react-color@^2.17.3: reactcss "^1.2.0" tinycolor2 "^1.4.1" +react-dnd-html5-backend@^11.1.3: + version "11.1.3" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-11.1.3.tgz#2749f04f416ec230ea193f5c1fbea2de7dffb8f7" + integrity sha512-/1FjNlJbW/ivkUxlxQd7o3trA5DE33QiRZgxent3zKme8DwF4Nbw3OFVhTRFGaYhHFNL1rZt6Rdj1D78BjnNLw== + dependencies: + dnd-core "^11.1.3" + +react-dnd@^11.1.3: + version "11.1.3" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-11.1.3.tgz#f9844f5699ccc55dfc81462c2c19f726e670c1af" + integrity sha512-8rtzzT8iwHgdSC89VktwhqdKKtfXaAyC4wiqp0SywpHG12TTLvfOoL6xNEIUWXwIEWu+CFfDn4GZJyynCEuHIQ== + dependencies: + "@react-dnd/shallowequal" "^2.0.0" + "@types/hoist-non-react-statics" "^3.3.1" + dnd-core "^11.1.3" + hoist-non-react-statics "^3.3.0" + react-dom@^16.8.6: version "16.14.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" @@ -10027,6 +10094,14 @@ redis@^3.0.0: redis-errors "^1.2.0" redis-parser "^3.0.0" +redux@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + referrer-policy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e" @@ -11387,6 +11462,11 @@ supports-hyperlinks@^2.0.0: has-flag "^4.0.0" supports-color "^7.0.0" +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"