fix: Allow dragging shared documents to starred section (#7506)

* fix: Allow dragging shared documents to starred section

* fix: Allow read-only collection drag and drop
fix: Full screen delete modal from drag and drop
This commit is contained in:
Tom Moor
2024-09-01 17:19:40 -04:00
committed by GitHub
parent b95eb114f1
commit 1491fc2eb4
12 changed files with 239 additions and 152 deletions

View File

@@ -5,7 +5,6 @@ import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { NavigationNode } from "@shared/types";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -39,9 +38,6 @@ const CollectionLink: React.FC<Props> = ({
onDisclosureClick,
isDraggingAnyCollection,
}: Props) => {
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);
@@ -86,8 +82,6 @@ const CollectionLink: React.FC<Props> = ({
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
itemRef.current = item;
dialogs.openModal({
title: t("Move document"),
content: (

View File

@@ -2,11 +2,9 @@ import { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
@@ -29,6 +27,7 @@ import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useDragDocument, useDropToReorderDocument } from "./useDragAndDrop";
type Props = {
node: NavigationNode;
@@ -142,30 +141,12 @@ function InnerDocumentLink(
);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
const can = policies.abilities(node.id);
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag, preview] = useDrag({
type: "document",
item: () => ({
...node,
depth,
icon: icon ? <Icon value={icon} color={color} /> : undefined,
active: isActiveDocument,
collectionId: collection?.id || "",
}),
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
canDrag: () => can.move || can.archive || can.delete,
});
React.useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
@@ -196,10 +177,11 @@ function InnerDocumentLink(
setExpanded(true);
},
canDrop: (item, monitor) =>
!isDraft &&
!!pathToNode &&
!pathToNode.includes(monitor.getItem<DragObject>().id) &&
item.id !== node.id,
item.id !== node.id &&
policies.abilities(node.id).update &&
policies.abilities(item.id).move,
hover: (_item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
@@ -234,47 +216,26 @@ function InnerDocumentLink(
});
// Drop to reorder
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort) {
toast.message(
t(
"You can't reorder documents in an alphabetically sorted collection"
)
);
return;
}
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
useDropToReorderDocument(node, collection, (item) => {
if (!collection) {
return;
}
if (item.id === node.id) {
return;
}
if (expanded) {
void documents.move({
return {
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
index: 0,
});
return;
};
}
void documents.move({
return {
documentId: item.id,
collectionId: collection.id,
parentDocumentId: parentId,
index: index + 1,
});
},
collect: (monitor) => ({
isOverReorder: monitor.isOver(),
isDraggingAnyDocument: monitor.canDrop(),
}),
});
};
});
const nodeChildren = React.useMemo(() => {
const insertDraftDocument =
@@ -407,7 +368,7 @@ function InnerDocumentLink(
</DropToImport>
</div>
</Draggable>
{isDraggingAnyDocument && manualSort && (
{isDraggingAnyDocument && collection?.isManualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>

View File

@@ -7,7 +7,6 @@ import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useLocationState } from "../hooks/useLocationState";
import CollectionLink from "./CollectionLink";
@@ -32,12 +31,11 @@ function DraggableCollectionLink({
}: Props) {
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const { ui, collections } = useStores();
const { ui, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
);
const can = usePolicy(collection);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to reorder collection
@@ -54,7 +52,8 @@ function DraggableCollectionLink({
},
canDrop: (item) =>
collection.id !== item.id &&
(!belowCollection || item.id !== belowCollection.id),
(!belowCollection || item.id !== belowCollection.id) &&
policies.abilities(item.id)?.move,
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnyCollection: monitor.canDrop(),
@@ -72,7 +71,6 @@ function DraggableCollectionLink({
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
canDrag: () => can.move,
});
React.useEffect(() => {

View File

@@ -34,8 +34,8 @@ function SharedWithMe() {
});
// Drop to reorder document
const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership(
() => fractionalIndex(null, user.documentMemberships[0].index)
const [reorderProps, dropToReorderRef] = useDropToReorderUserMembership(() =>
fractionalIndex(null, user.documentMemberships[0].index)
);
React.useEffect(() => {
@@ -59,9 +59,9 @@ function SharedWithMe() {
<GroupLink key={group.id} group={group} />
))}
<Relative>
{reorderMonitor.isDragging && (
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderMonitor.isOverCursor}
isActiveDrop={reorderProps.isOverCursor}
innerRef={dropToReorderRef}
position="top"
/>

View File

@@ -88,7 +88,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
}
return "";
};
const [reorderMonitor, dropToReorderRef] =
const [reorderProps, dropToReorderRef] =
useDropToReorderUserMembership(getIndex);
const displayChildDocuments = expanded && !isDragging;
@@ -168,9 +168,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
/>
))}
</Folder>
{reorderMonitor.isDragging && (
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderMonitor.isOverCursor}
isActiveDrop={reorderProps.isOverCursor}
innerRef={dropToReorderRef}
/>
)}

View File

@@ -14,7 +14,6 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
export type DragObject = NavigationNode & {
depth: number;
active: boolean;
collectionId: string;
};

View File

@@ -25,8 +25,8 @@ function Starred() {
const { loading, next, end, error, page } = usePaginatedRequest<Star>(
stars.fetchPage
);
const [reorderStarMonitor, dropToReorder] = useDropToReorderStar();
const [createStarMonitor, dropToStarRef] = useDropToCreateStar();
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
const [createStarProps, dropToStarRef] = useDropToCreateStar();
React.useEffect(() => {
if (error) {
@@ -43,16 +43,16 @@ function Starred() {
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarMonitor.isDragging && (
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarMonitor.isOverCursor}
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarMonitor.isDragging && (
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarMonitor.isOverCursor}
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>

View File

@@ -84,22 +84,22 @@ function StarredLink({ star }: Props) {
<StarredIcon color={theme.yellow} />
);
const [{ isDragging }, draggableRef] = useDragStar(star);
const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex);
const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex);
const [reorderStarProps, dropToReorderRef] = useDropToReorderStar(getIndex);
const [createStarProps, dropToStarRef] = useDropToCreateStar(getIndex);
const displayChildDocuments = expanded && !isDragging;
const cursor = (
<>
{reorderStarMonitor.isDragging && (
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarMonitor.isOverCursor}
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorderRef}
/>
)}
{createStarMonitor.isDragging && (
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarMonitor.isOverCursor}
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
/>
)}
@@ -183,7 +183,7 @@ function StarredLink({ star }: Props) {
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarMonitor.isDragging}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<SidebarContext.Provider value={collection.id}>

View File

@@ -1,29 +1,36 @@
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
import { trashPath } from "~/utils/routeHelpers";
import SidebarLink, { DragObject } from "./SidebarLink";
function TrashLink() {
const { policies, documents } = useStores();
const { policies, dialogs, documents } = useStores();
const { t } = useTranslation();
const [document, setDocument] = useState<Document>();
const [{ isDocumentDropping }, dropToTrashDocument] = useDrop({
const [{ isDocumentDropping }, dropToTrashRef] = useDrop({
accept: "document",
drop: (item: DragObject) => {
const doc = documents.get(item.id);
drop: async (item: DragObject) => {
const document = documents.get(item.id);
if (!document) {
return;
}
// without setTimeout it was not working in firefox v89.0.2-ubuntu
// on dropping mouseup is considered as clicking outside the modal, and it immediately closes
setTimeout(() => doc && setDocument(doc), 1);
dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: document?.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
canDrop: (item) => policies.abilities(item.id).delete,
collect: (monitor) => ({
@@ -32,32 +39,16 @@ function TrashLink() {
});
return (
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to={trashPath()}
icon={<TrashIcon open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
active={documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
{document && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setDocument(undefined)}
isOpen
>
<DocumentDelete
document={document}
onSubmit={() => setDocument(undefined)}
/>
</Modal>
)}
</>
<div ref={dropToTrashRef}>
<SidebarLink
to={trashPath()}
icon={<TrashIcon open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
active={documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
);
}

View File

@@ -3,10 +3,16 @@ import { StarredIcon } from "outline-icons";
import * as React from "react";
import { ConnectDragSource, useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import GroupMembership from "~/models/GroupMembership";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { DragObject } from "./SidebarLink";
@@ -32,7 +38,6 @@ export function useDragStar(
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => true,
});
React.useEffect(() => {
@@ -48,21 +53,41 @@ export function useDragStar(
* @param getIndex A function to get the index of the current item where the star should be inserted.
*/
export function useDropToCreateStar(getIndex?: () => string) {
const { documents, stars, collections } = useStores();
const accept = [
"document",
"collection",
"userMembership",
"groupMembership",
];
const { documents, stars, collections, userMemberships, groupMemberships } =
useStores();
return useDrop({
accept: ["document", "collection"],
drop: async (item: DragObject) => {
const model = documents.get(item.id) ?? collections?.get(item.id);
return useDrop<
DragObject,
Promise<void>,
{ isOverCursor: boolean; isDragging: boolean }
>({
accept,
drop: async (item, monitor) => {
const type = monitor.getItemType();
let model;
if (type === "collection") {
model = collections.get(item.id);
} else if (type === "userMembership") {
model = userMemberships.get(item.id)?.document;
} else if (type === "groupMembership") {
model = groupMemberships.get(item.id)?.document;
} else {
model = documents.get(item.id);
}
await model?.star(
getIndex?.() ?? fractionalIndex(null, stars.orderedData[0].index)
);
},
collect: (monitor) => ({
isOverCursor: !!monitor.isOver(),
isDragging: ["document", "collection"].includes(
String(monitor.getItemType())
),
isDragging: accept.includes(String(monitor.getItemType())),
}),
});
}
@@ -75,9 +100,13 @@ export function useDropToCreateStar(getIndex?: () => string) {
export function useDropToReorderStar(getIndex?: () => string) {
const { stars } = useStores();
return useDrop({
return useDrop<
DragObject,
Promise<void>,
{ isOverCursor: boolean; isDragging: boolean }
>({
accept: "star",
drop: async (item: DragObject) => {
drop: async (item) => {
const star = stars.get(item.id);
void star?.save({
index:
@@ -92,34 +121,138 @@ export function useDropToReorderStar(getIndex?: () => string) {
}
/**
* Hook for shared logic that allows dragging user memberships to reorder
* Hook for shared logic that allows dragging documents.
*
* @param membership The UserMembership or GroupMembership model to drag.
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
*/
export function useDragMembership(
membership: UserMembership | GroupMembership
): [{ isDragging: boolean }, ConnectDragSource] {
const id = membership.id;
const { label: title, icon } = useSidebarLabelAndIcon(membership);
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
const [{ isDragging }, draggableRef, preview] = useDrag({
type: "userMembership",
item: () => ({
id,
title,
icon,
}),
const [{ isDragging }, draggableRef, preview] = useDrag<
DragObject,
Promise<void>,
{ isDragging: boolean }
>({
type: "document",
item: () =>
({
...node,
depth,
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
isDragging: monitor.isDragging(),
}),
canDrag: () => membership instanceof UserMembership,
});
React.useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
return [{ isDragging }, draggableRef];
return [{ isDragging }, draggableRef] as const;
}
/**
* Hook for shared logic that allows dropping documents to reorder
*
* @param node The NavigationNode model to drop.
* @param collection The related Collection model, if published
* @param getMoveParams A function to get the move parameters for the document.
*/
export function useDropToReorderDocument(
node: NavigationNode,
collection: Collection | undefined,
getMoveParams: (item: DragObject) =>
| undefined
| {
documentId: string;
collectionId: string;
parentDocumentId: string | undefined;
index: number;
}
) {
const { t } = useTranslation();
const { documents, policies } = useStores();
return useDrop<
DragObject,
Promise<void>,
{ isOverReorder: boolean; isDraggingAnyDocument: boolean }
>({
accept: "document",
canDrop: (item: DragObject) => {
if (item.id === node.id) {
return false;
}
return policies.abilities(item.id)?.move;
},
drop: async (item) => {
if (!collection?.isManualSort && item.collectionId === collection?.id) {
toast.message(
t(
"You can't reorder documents in an alphabetically sorted collection"
)
);
return;
}
const params = getMoveParams(item);
if (params) {
void documents.move(params);
}
},
collect: (monitor) => ({
isOverReorder: monitor.isOver(),
isDraggingAnyDocument: monitor.canDrop(),
}),
});
}
/**
* Hook for shared logic that allows dragging user memberships.
*
* @param membership The UserMembership or GroupMembership model to drag.
*/
export function useDragMembership(
membership: UserMembership | GroupMembership
) {
const id = membership.id;
const { label: title, icon } = useSidebarLabelAndIcon(membership);
const [{ isDragging }, draggableRef, preview] = useDrag<
DragObject,
Promise<void>,
{ isDragging: boolean }
>({
type:
membership instanceof UserMembership
? "userMembership"
: "groupMembership",
item: () =>
({
id,
title,
icon,
} as DragObject),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
});
React.useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
return [{ isDragging }, draggableRef] as const;
}
/**
@@ -131,9 +264,13 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
const { userMemberships } = useStores();
const user = useCurrentUser();
return useDrop({
return useDrop<
DragObject,
Promise<void>,
{ isOverCursor: boolean; isDragging: boolean }
>({
accept: "userMembership",
drop: async (item: DragObject) => {
drop: async (item) => {
const userMembership = userMemberships.get(item.id);
void userMembership?.save({
index:

View File

@@ -154,6 +154,11 @@ export default class Collection extends ParanoidModel {
);
}
@computed
get isManualSort(): boolean {
return this.sort.field === "index";
}
@computed
get sortedDocuments(): NavigationNode[] | undefined {
if (!this.documents) {

View File

@@ -228,6 +228,8 @@ export type TeamPreferences = {
export enum NavigationNodeType {
Collection = "collection",
Document = "document",
UserMembership = "userMembership",
GroupMembership = "groupMembership",
}
export type NavigationNode = {