Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Drag and drop improvements #264

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 9 additions & 68 deletions apps/web/components/dashboard/tags/AllTagsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,11 @@ import InfoTooltip from "@/components/ui/info-tooltip";
import { Separator } from "@/components/ui/separator";
import { Toggle } from "@/components/ui/toggle";
import { toast } from "@/components/ui/use-toast";
import { useDragAndDrop } from "@/lib/drag-and-drop";
import { api } from "@/lib/trpc";
import { ArrowDownAZ, Combine } from "lucide-react";
import Draggable from "react-draggable";

import type { ZGetTagResponse } from "@hoarder/shared/types/tags";
import {
useDeleteUnusedTags,
useMergeTag,
} from "@hoarder/shared-react/hooks/tags";
import { useDeleteUnusedTags } from "@hoarder/shared-react/hooks/tags";

import { TagPill } from "./TagPill";

Expand Down Expand Up @@ -79,17 +74,6 @@ export default function AllTagsView({
const [draggingEnabled, setDraggingEnabled] = React.useState(false);
const [sortByName, setSortByName] = React.useState(false);

const { handleDragStart, handleDragEnd } = useDragAndDrop(
"data-id",
"data-id",
(dragSourceId: string, dragTargetId: string) => {
mergeTag({
fromTagIds: [dragSourceId],
intoTagId: dragTargetId,
});
},
);

function toggleSortByName(): void {
setSortByName(!sortByName);
}
Expand All @@ -98,40 +82,9 @@ export default function AllTagsView({
setDraggingEnabled(!draggingEnabled);
}

const { mutate: mergeTag } = useMergeTag({
onSuccess: () => {
toast({
description: "Tags have been merged!",
});
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
if (e.data.zodError) {
toast({
variant: "destructive",
description: Object.values(e.data.zodError.fieldErrors)
.flat()
.join("\n"),
});
} else {
toast({
variant: "destructive",
description: e.message,
});
}
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});

const { data } = api.tags.list.useQuery(undefined, {
initialData: { tags: initialData },
});

// Sort tags by usage desc
const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter);

Expand All @@ -145,21 +98,13 @@ export default function AllTagsView({
tagPill = (
<div className="flex flex-wrap gap-3">
{tags.map((t) => (
<Draggable
<TagPill
key={t.id}
axis="both"
onStart={handleDragStart}
onStop={handleDragEnd}
disabled={!draggingEnabled}
defaultClassNameDragging={
"position-relative z-10 pointer-events-none"
}
position={{ x: 0, y: 0 }}
>
<div className="cursor-grab" data-id={t.id}>
<TagPill id={t.id} name={t.name} count={t.count} />
</div>
</Draggable>
id={t.id}
name={t.name}
count={t.count}
isDraggable={draggingEnabled}
/>
))}
</div>
);
Expand All @@ -173,6 +118,7 @@ export default function AllTagsView({
<div className="flex justify-end gap-x-2">
<Toggle
variant="outline"
aria-label="Toggle bold"
pressed={draggingEnabled}
onPressedChange={toggleDraggingEnabled}
>
Expand All @@ -184,6 +130,7 @@ export default function AllTagsView({
</Toggle>
<Toggle
variant="outline"
aria-label="Toggle bold"
pressed={sortByName}
onPressedChange={toggleSortByName}
>
Expand All @@ -196,22 +143,16 @@ export default function AllTagsView({
<p>Tags that were attached at least once by you</p>
</InfoTooltip>
</span>

{tagsToPill(humanTags)}

<Separator />

<span className="flex items-center gap-2">
<p className="text-lg">AI Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
<p>Tags that were only attached automatically (by AI)</p>
</InfoTooltip>
</span>

{tagsToPill(aiTags)}

<Separator />

<span className="flex items-center gap-2">
<p className="text-lg">Unused Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
Expand Down
96 changes: 77 additions & 19 deletions apps/web/components/dashboard/tags/TagPill.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,98 @@
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { toast } from "@/components/ui/use-toast";
import { useDragAndDrop } from "@/lib/drag-and-drop";
import { X } from "lucide-react";
import Draggable from "react-draggable";

import { useMergeTag } from "@hoarder/shared-react/hooks/tags";

import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";

export function TagPill({
id,
name,
count,
isDraggable,
}: {
id: string;
name: string;
count: number;
isDraggable: boolean;
}) {
return (
<div className="group relative flex">
<Link
className={
"flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background"
const { mutate: mergeTag } = useMergeTag({
onSuccess: () => {
toast({
description: "Tags have been merged!",
});
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
if (e.data.zodError) {
toast({
variant: "destructive",
description: Object.values(e.data.zodError.fieldErrors)
.flat()
.join("\n"),
});
} else {
toast({
variant: "destructive",
description: e.message,
});
}
href={`/dashboard/tags/${id}`}
data-id={id}
>
{name} <Separator orientation="vertical" /> {count}
</Link>
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});

const dragAndDropFunction = useDragAndDrop(
"data-id",
(dragTargetId: string) => {
mergeTag({
fromTagIds: [id],
intoTagId: dragTargetId,
});
},
);

<DeleteTagConfirmationDialog tag={{ name, id }}>
<Button
size="none"
variant="secondary"
className="-translate-1/2 absolute -right-1 -top-1 hidden rounded-full group-hover:block"
return (
<Draggable
key={id}
axis="both"
onStart={dragAndDropFunction.handleDragStart}
onStop={dragAndDropFunction.handleDragEnd}
disabled={!isDraggable}
defaultClassNameDragging={"position-relative z-10 pointer-events-none"}
position={{ x: 0, y: 0 }}
>
<div className="group relative flex">
<Link
className={
"flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background"
}
href={`/dashboard/tags/${id}`}
data-id={id}
>
<X className="size-3" />
</Button>
</DeleteTagConfirmationDialog>
</div>
{name} <Separator orientation="vertical" /> {count}
</Link>

<DeleteTagConfirmationDialog tag={{ name, id }}>
<Button
size="none"
variant="secondary"
className="-translate-1/2 absolute -right-1 -top-1 hidden rounded-full group-hover:block"
>
<X className="size-3" />
</Button>
</DeleteTagConfirmationDialog>
</div>
</Draggable>
);
}
55 changes: 38 additions & 17 deletions apps/web/lib/drag-and-drop.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,65 @@
import React from "react";
import { DraggableData, DraggableEvent } from "react-draggable";
import { DraggableEvent } from "react-draggable";

export interface DraggingState {
isDragging: boolean;
initialX: number;
initialY: number;
}

export function useDragAndDrop(
dragSourceIdAttribute: string,
dragTargetIdAttribute: string,
onDragOver: (dragSourceId: string, dragTargetId: string) => void,
onDragOver: (dragTargetId: string) => void,
setDraggingState?: React.Dispatch<React.SetStateAction<DraggingState>>,
) {
const [dragSourceId, setDragSourceId] = React.useState<string | null>(null);
function findTargetId(element: HTMLElement): string | null {
let currentElement: HTMLElement | null = element;
while (currentElement) {
const listId = currentElement.getAttribute(dragTargetIdAttribute);
if (listId) {
return listId;
}
currentElement = currentElement.parentElement;
}
return null;
}

const handleDragStart = React.useCallback(
(_e: DraggableEvent, { node }: DraggableData) => {
const id = node.getAttribute(dragSourceIdAttribute);
setDragSourceId(id);
(e: DraggableEvent) => {
const rect = (e.target as HTMLElement).getBoundingClientRect();
setDraggingState?.({
isDragging: true,
initialX: rect.x,
initialY: rect.y,
});
},
[],
[setDraggingState],
);

const handleDragEnd = React.useCallback(
(e: DraggableEvent) => {
const { target } = e;
const dragTargetId = (target as HTMLElement).getAttribute(
dragTargetIdAttribute,
);
const dragTargetId = findTargetId(target as HTMLElement);

if (dragSourceId && dragTargetId && dragSourceId !== dragTargetId) {
/*
As Draggable tries to setState when the
if (dragTargetId) {
/* As Draggable tries to setState when the
component is unmounted, it is needed to
push onCombine to the event loop queue.
onCombine would be run after setState on
Draggable so it would fix the issue until
they fix it on their end.
*/
setTimeout(() => {
onDragOver(dragSourceId, dragTargetId);
onDragOver(dragTargetId);
}, 0);
}
setDragSourceId(null);
setDraggingState?.({
isDragging: false,
initialX: 0,
initialY: 0,
});
},
[dragSourceId, onDragOver],
[onDragOver],
);

return {
Expand Down
Loading