From 67729c131c92a2fab6d1422db34aa000c348af07 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Sun, 1 Sep 2024 19:15:24 +0000 Subject: feature(web): Manage tags in bulk actions --- .../components/dashboard/BulkBookmarksAction.tsx | 16 ++- .../dashboard/bookmarks/BookmarkTagsEditor.tsx | 48 ++++++++ .../dashboard/bookmarks/BulkTagModal.tsx | 126 +++++++++++++++++++++ .../components/dashboard/bookmarks/TagModal.tsx | 4 +- .../components/dashboard/bookmarks/TagsEditor.tsx | 60 ++++------ .../dashboard/preview/BookmarkPreview.tsx | 4 +- 6 files changed, 214 insertions(+), 44 deletions(-) create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BulkTagModal.tsx diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index 383b9a4e..2e6fc75b 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -8,7 +8,7 @@ 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, @@ -16,6 +16,7 @@ import { } 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() { @@ -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 @@ -104,6 +106,13 @@ export default function BulkBookmarksAction() { isPending: false, hidden: !isBulkEditEnabled, }, + { + name: "Edit Tags", + icon: , + action: () => setBulkTagModalOpen(true), + isPending: false, + hidden: !isBulkEditEnabled, + }, { name: alreadyFavourited ? "Unfavourite" : "Favourite", icon: , @@ -163,6 +172,11 @@ export default function BulkBookmarksAction() { open={manageListsModal} setOpen={setManageListsModalOpen} /> + b.id)} + open={bulkTagModal} + setOpen={setBulkTagModalOpen} + />
{isBulkEditEnabled && (

diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx new file mode 100644 index 00000000..d6d60d22 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx @@ -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 ( + { + mutate({ + bookmarkId: bookmark.id, + attach: [ + { + tagName, + tagId, + }, + ], + detach: [], + }); + }} + onDetach={({ tagId }) => { + mutate({ + bookmarkId: bookmark.id, + attach: [], + detach: [{ tagId }], + }); + }} + /> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx new file mode 100644 index 00000000..3c8e75e7 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx @@ -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 ( +

+ + + Edit Tags of {bookmarks.length} Bookmarks + + + + + + + + + + ); +} diff --git a/apps/web/components/dashboard/bookmarks/TagModal.tsx b/apps/web/components/dashboard/bookmarks/TagModal.tsx index 00cc40fc..c2f081be 100644 --- a/apps/web/components/dashboard/bookmarks/TagModal.tsx +++ b/apps/web/components/dashboard/bookmarks/TagModal.tsx @@ -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, @@ -28,7 +28,7 @@ export default function TagModal({ Edit Tags - +