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

Improve merging of tags by simple drag and drop #144 #154

Merged
merged 5 commits into from
May 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
211 changes: 173 additions & 38 deletions apps/web/components/dashboard/tags/AllTagsView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import Link from "next/link";
import React from "react";
import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { Button } from "@/components/ui/button";
Expand All @@ -13,12 +13,15 @@ import InfoTooltip from "@/components/ui/info-tooltip";
import { Separator } from "@/components/ui/separator";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { X } from "lucide-react";
import Draggable, { DraggableData, DraggableEvent } from "react-draggable";

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

import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";
import { TagPill } from "./TagPill";

function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) {
const { mutate, isPending } = useDeleteUnusedTags({
Expand Down Expand Up @@ -54,48 +57,128 @@ function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) {
</ActionConfirmingDialog>
);
}

function TagPill({
id,
name,
count,
}: {
id: string;
name: string;
count: number;
}) {
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"
href={`/dashboard/tags/${id}`}
>
{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>
);
interface DragState {
dragId: string | null;
dragOverId: string | null;
x: number;
y: number;
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved
}

const initialState: DragState = {
dragId: "",
dragOverId: "",
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved
x: 0,
y: 0,
};
let currentState: DragState = initialState;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a react expert but I think global mutable state are frowned upon? Is it not possible to do this with a React.useState instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not an expert either^^
I have however extracted it out now, since that functionality is pretty much the same for the drag&drop functionality for assigning bookmarks to lists (next PR ;-) ), so it makes sense to reuse it.
It is now more encapsulated, so I guess that concern should be gone, but let me know.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, after learning a lot, i found this:
useState needs to be used for the change detection to work, but the drag&drop handlers are quite slow to execute and react-draggable is not implemented that well. Due to the processing overhead, the

will drift away from the mouse over time (lagging behind) and it looks stupid.
My followup PR for also implementing drag&drop for assigning bookmarks to lists has therefore some changes:

  • The components themselves keep their state instead of the parent --> more fine grained rerender decisions possible
  • most of the state is kept outside of useState and instead in a simple closure, to make sure everything is still fast enough
  • the isDragging state is kept with useState, which makes a rerender possible, when the user starts to drag.


const byUsageSorter = (a: ZGetTagResponse, b: ZGetTagResponse) =>
b.count - a.count;
const byNameSorter = (a: ZGetTagResponse, b: ZGetTagResponse) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved

export default function AllTagsView({
initialData,
}: {
initialData: ZGetTagResponse[];
}) {
const [draggingEnabled, setDraggingEnabled] = React.useState(false);
const [sortByName, setSortByName] = React.useState(false);

function handleSortByNameChange(): void {
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved
setSortByName(!sortByName);
}

function handleDraggableChange(): void {
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved
setDraggingEnabled(!draggingEnabled);
}

function handleDragStart(e: DraggableEvent, data: DraggableData): void {
const { node } = data;
const id = node.getAttribute("data-id");

currentState = {
...initialState,
dragId: id,
x: data.x,
y: data.y,
};
}

function handleDrag(e: DraggableEvent): void {
const { dragOverId } = currentState;
const { target } = e;

// Important according to the sample I found
e.preventDefault();

if (target) {
const id = (target as HTMLElement).getAttribute("data-id");

if (id !== dragOverId) {
currentState.dragOverId = id;
}
}
}

function handleDragEnd(): void {
const { dragId, dragOverId } = currentState;

if (dragId && dragOverId && dragId !== dragOverId) {
/*
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(() => {
mergeTag({
fromTagIds: [dragId],
intoTagId: dragOverId,
});
}, 0);
}

currentState = initialState;
}

const { mutate: mergeTag } = useMergeTag({
onSuccess: () => {
toast({
description: "Tag has been updated!",
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved
});
},
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((a, b) => b.count - a.count);
const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter);

const humanTags = allTags.filter((t) => (t.countAttachedBy.human ?? 0) > 0);
const aiTags = allTags.filter((t) => (t.countAttachedBy.ai ?? 0) > 0);
Expand All @@ -104,22 +187,73 @@ export default function AllTagsView({
const tagsToPill = (tags: typeof allTags) => {
let tagPill;
if (tags.length) {
tagPill = tags.map((t) => (
<TagPill key={t.id} id={t.id} name={t.name} count={t.count} />
));
tagPill = (
<div className="flex flex-wrap gap-3">
MohamedBassem marked this conversation as resolved.
Show resolved Hide resolved
{tags.map((t) => (
<Draggable
key={t.id}
axis="both"
onStart={handleDragStart}
onDrag={handleDrag}
onStop={handleDragEnd}
disabled={!draggingEnabled}
defaultClassNameDragging={
"position-relative z-10 pointer-events-none"
}
position={
!currentState.dragId
? { x: currentState.x ?? 0, y: currentState.y ?? 0 }
: undefined
}
>
<div
className="group relative flex cursor-grab"
data-id={t.id}
onDragOver={handleDrag}
>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we pass onDragOver to TagPill and remove that div?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turns out the onDragOver is not actually needed, so I removed it.
If I remove the div though, it is suddenly not possible to drag it anymore. I am not sure why though...

<TagPill
key={t.id}
kamtschatka marked this conversation as resolved.
Show resolved Hide resolved
id={t.id}
name={t.name}
count={t.count}
isDraggable={draggingEnabled}
/>
</div>
</Draggable>
))}
</div>
);
} else {
tagPill = "No Tags";
}
return tagPill;
};
return (
<>
<label>
<input
type="checkbox"
checked={draggingEnabled}
onChange={handleDraggableChange}
/>
Allow Merging via Drag&Drop
</label>
<br />
<label>
<input
type="checkbox"
checked={sortByName}
onChange={handleSortByNameChange}
/>
Sort by Name
</label>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think those can probably be shadcn toggles (https://ui.shadcn.com/docs/components/toggle) and can probably be aligned right in the page.
If UI design is not your expertise, I'm happy to implement it myself after merging your PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
looks like this now

<span className="flex items-center gap-2">
<p className="text-lg">Your Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
<p>Tags that were attached at least once by you</p>
</InfoTooltip>
</span>

<div className="flex flex-wrap gap-3">{tagsToPill(humanTags)}</div>

<Separator />
Expand All @@ -130,6 +264,7 @@ export default function AllTagsView({
<p>Tags that were only attached automatically (by AI)</p>
</InfoTooltip>
</span>

<div className="flex flex-wrap gap-3">{tagsToPill(aiTags)}</div>

<Separator />
Expand Down
48 changes: 48 additions & 0 deletions apps/web/components/dashboard/tags/TagPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { X } from "lucide-react";

import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";

const PILL_STYLE =
"flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background";

export function TagPill({
id,
name,
count,
isDraggable,
}: {
id: string;
name: string;
count: number;
isDraggable: boolean;
}) {
// When the element is draggable, do not generate a link. Links can be dragged into e.g. the tab-bar and therefore dragging the TagPill does not work properly
if (isDraggable) {
return (
<div className={PILL_STYLE} data-id={id}>
{name} <Separator orientation="vertical" /> {count}
</div>
);
}

return (
<div className="group relative flex">
<Link className={PILL_STYLE} href={`/dashboard/tags/${id}`}>
{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>
);
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.50.1",
"react-markdown": "^9.0.1",
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading