Skip to content

Commit

Permalink
feature(web): Manage tags in bulk actions
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Sep 1, 2024
1 parent 6d4d1a6 commit 67729c1
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 44 deletions.
16 changes: 15 additions & 1 deletion apps/web/components/dashboard/BulkBookmarksAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import {
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { useToast } from "@/components/ui/use-toast";
import useBulkActionsStore from "@/lib/bulkActions";
import { CheckCheck, List, Pencil, Trash2, X } from "lucide-react";
import { CheckCheck, Hash, List, Pencil, Trash2, X } from "lucide-react";

import {
useDeleteBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks/bookmarks";

import BulkManageListsModal from "./bookmarks/BulkManageListsModal";
import BulkTagModal from "./bookmarks/BulkTagModal";
import { ArchivedActionIcon, FavouritedActionIcon } from "./bookmarks/icons";

export default function BulkBookmarksAction() {
Expand All @@ -26,6 +27,7 @@ export default function BulkBookmarksAction() {
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [manageListsModal, setManageListsModalOpen] = useState(false);
const [bulkTagModal, setBulkTagModalOpen] = useState(false);

useEffect(() => {
setIsBulkEditEnabled(false); // turn off toggle + clear selected bookmarks on mount
Expand Down Expand Up @@ -104,6 +106,13 @@ export default function BulkBookmarksAction() {
isPending: false,
hidden: !isBulkEditEnabled,
},
{
name: "Edit Tags",
icon: <Hash size={18} />,
action: () => setBulkTagModalOpen(true),
isPending: false,
hidden: !isBulkEditEnabled,
},
{
name: alreadyFavourited ? "Unfavourite" : "Favourite",
icon: <FavouritedActionIcon favourited={!!alreadyFavourited} size={18} />,
Expand Down Expand Up @@ -163,6 +172,11 @@ export default function BulkBookmarksAction() {
open={manageListsModal}
setOpen={setManageListsModalOpen}
/>
<BulkTagModal
bookmarkIds={selectedBookmarks.map((b) => b.id)}
open={bulkTagModal}
setOpen={setBulkTagModalOpen}
/>
<div className="flex items-center">
{isBulkEditEnabled && (
<p className="flex items-center gap-2">
Expand Down
48 changes: 48 additions & 0 deletions apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { toast } from "@/components/ui/use-toast";

import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";

import { TagsEditor } from "./TagsEditor";

export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
const { mutate } = useUpdateBookmarkTags({
onSuccess: () => {
toast({
description: "Tags has been updated!",
});
},
onError: () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
},
});

return (
<TagsEditor
tags={bookmark.tags}
onAttach={({ tagName, tagId }) => {
mutate({
bookmarkId: bookmark.id,
attach: [
{
tagName,
tagId,
},
],
detach: [],
});
}}
onDetach={({ tagId }) => {
mutate({
bookmarkId: bookmark.id,
attach: [],
detach: [{ tagId }],
});
}}
/>
);
}
126 changes: 126 additions & 0 deletions apps/web/components/dashboard/bookmarks/BulkTagModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";

import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
import { api } from "@hoarder/shared-react/trpc";
import { ZBookmark } from "@hoarder/shared/types/bookmarks";

import { TagsEditor } from "./TagsEditor";

export default function BulkTagModal({
bookmarkIds,
open,
setOpen,
}: {
bookmarkIds: string[];
open: boolean;
setOpen: (open: boolean) => void;
}) {
const results = api.useQueries((t) =>
bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })),
);

const bookmarks = results
.map((r) => r.data)
.filter((b): b is ZBookmark => !!b);

const { mutateAsync } = useUpdateBookmarkTags({
onError: (err) => {
if (err.data?.code == "BAD_REQUEST") {
if (err.data.zodError) {
toast({
variant: "destructive",
description: Object.values(err.data.zodError.fieldErrors)
.flat()
.join("\n"),
});
} else {
toast({
variant: "destructive",
description: err.message,
});
}
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});

const onAttach = async (tag: { tagName: string; tagId?: string }) => {
const results = await Promise.allSettled(
bookmarkIds.map((id) =>
mutateAsync({
bookmarkId: id,
attach: [tag],
detach: [],
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
toast({
description: `Tag "${tag.tagName}" has been added to ${successes} bookmarks!`,
});
};

const onDetach = async ({
tagId,
tagName,
}: {
tagId: string;
tagName: string;
}) => {
const results = await Promise.allSettled(
bookmarkIds.map((id) =>
mutateAsync({
bookmarkId: id,
attach: [],
detach: [{ tagId }],
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
toast({
description: `Tag "${tagName}" has been removed from ${successes} bookmarks!`,
});
};

// Get all the tags that are attached to all the bookmarks
let tags = bookmarks
.flatMap((b) => b.tags)
.filter((tag) =>
bookmarks.every((b) => b.tags.some((t) => tag.id == t.id)),
);
// Filter duplicates
tags = tags.filter(
(tag, index, self) => index === self.findIndex((t) => t.id == tag.id),
);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tags of {bookmarks.length} Bookmarks</DialogTitle>
</DialogHeader>
<TagsEditor tags={tags} onAttach={onAttach} onDetach={onDetach} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
4 changes: 2 additions & 2 deletions apps/web/components/dashboard/bookmarks/TagModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {

import type { ZBookmark } from "@hoarder/shared/types/bookmarks";

import { TagsEditor } from "./TagsEditor";
import { BookmarkTagsEditor } from "./BookmarkTagsEditor";

export default function TagModal({
bookmark,
Expand All @@ -28,7 +28,7 @@ export default function TagModal({
<DialogHeader>
<DialogTitle>Edit Tags</DialogTitle>
</DialogHeader>
<TagsEditor bookmark={bookmark} />
<BookmarkTagsEditor bookmark={bookmark} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Expand Down
60 changes: 21 additions & 39 deletions apps/web/components/dashboard/bookmarks/TagsEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,32 @@
import type { ActionMeta } from "react-select";
import { toast } from "@/components/ui/use-toast";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import CreateableSelect from "react-select/creatable";

import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import type { ZAttachedByEnum } from "@hoarder/shared/types/tags";
import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
import type {
ZAttachedByEnum,
ZBookmarkTags,
} from "@hoarder/shared/types/tags";

interface EditableTag {
attachedBy: ZAttachedByEnum;
value?: string;
label: string;
}

export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) {
export function TagsEditor({
tags,
onAttach,
onDetach,
}: {
tags: ZBookmarkTags[];
onAttach: (tag: { tagName: string; tagId?: string }) => void;
onDetach: (tag: { tagName: string; tagId: string }) => void;
}) {
const demoMode = !!useClientConfig().demoMode;

const { mutate } = useUpdateBookmarkTags({
onSuccess: () => {
toast({
description: "Tags has been updated!",
});
},
onError: () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
},
});

const { data: existingTags, isLoading: isExistingTagsLoading } =
api.tags.list.useQuery();

Expand All @@ -47,33 +40,22 @@ export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) {
case "pop-value":
case "remove-value": {
if (actionMeta.removedValue.value) {
mutate({
bookmarkId: bookmark.id,
attach: [],
detach: [{ tagId: actionMeta.removedValue.value }],
onDetach({
tagId: actionMeta.removedValue.value,
tagName: actionMeta.removedValue.label,
});
}
break;
}
case "create-option": {
mutate({
bookmarkId: bookmark.id,
attach: [{ tagName: actionMeta.option.label }],
detach: [],
});
onAttach({ tagName: actionMeta.option.label });
break;
}
case "select-option": {
if (actionMeta.option) {
mutate({
bookmarkId: bookmark.id,
attach: [
{
tagName: actionMeta.option.label,
tagId: actionMeta.option?.value,
},
],
detach: [],
onAttach({
tagName: actionMeta.option.label,
tagId: actionMeta.option?.value,
});
}
break;
Expand All @@ -92,7 +74,7 @@ export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) {
attachedBy: "human" as const,
})) ?? []
}
value={bookmark.tags.map((t) => ({
value={tags.map((t) => ({
label: t.name,
value: t.id,
attachedBy: t.attachedBy,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/dashboard/preview/BookmarkPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from "react";
import Link from "next/link";
import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor";
import { BookmarkTagsEditor } from "@/components/dashboard/bookmarks/BookmarkTagsEditor";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
Expand Down Expand Up @@ -147,7 +147,7 @@ export default function BookmarkPreview({
<CreationTime createdAt={bookmark.createdAt} />
<div className="flex items-center gap-4">
<p className="text-sm text-gray-400">Tags</p>
<TagsEditor bookmark={bookmark} />
<BookmarkTagsEditor bookmark={bookmark} />
</div>
<div className="flex gap-4">
<p className="pt-2 text-sm text-gray-400">Note</p>
Expand Down

0 comments on commit 67729c1

Please sign in to comment.