From 60467f1d7fdc63e8ec3b10ad0d183248cebac4ee Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Sun, 17 Mar 2024 15:38:03 +0000 Subject: feature(web): A better tags editor using react select with auto complete and auto create --- apps/web/app/dashboard/tags/page.tsx | 16 +- .../components/dashboard/bookmarks/TagsEditor.tsx | 233 +++++++++++---------- apps/web/package.json | 1 + 3 files changed, 121 insertions(+), 129 deletions(-) (limited to 'apps/web') diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx index 08acd968..dec11527 100644 --- a/apps/web/app/dashboard/tags/page.tsx +++ b/apps/web/app/dashboard/tags/page.tsx @@ -1,11 +1,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { Separator } from "@/components/ui/separator"; +import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; -import { count, eq } from "drizzle-orm"; - -import { db } from "@hoarder/db"; -import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; function TagPill({ name, count }: { name: string; count: number }) { return ( @@ -24,16 +21,7 @@ export default async function TagsPage() { redirect("/"); } - let tags = await db - .select({ - id: tagsOnBookmarks.tagId, - name: bookmarkTags.name, - count: count(), - }) - .from(tagsOnBookmarks) - .where(eq(bookmarkTags.userId, session.user.id)) - .groupBy(tagsOnBookmarks.tagId) - .innerJoin(bookmarkTags, eq(bookmarkTags.id, tagsOnBookmarks.tagId)); + let tags = (await api.tags.list()).tags; // Sort tags by usage desc tags = tags.sort((a, b) => b.count - a.count); diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index 12c0dcd0..38f01bdd 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -1,134 +1,137 @@ -import type { KeyboardEvent } from "react"; -import { useEffect, useState } from "react"; -import { Input } from "@/components/ui/input"; +import type { ActionMeta } from "react-select"; import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; -import { Sparkles, X } from "lucide-react"; +import { Sparkles } from "lucide-react"; +import CreateableSelect from "react-select/creatable"; import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import type { ZAttachedByEnum } from "@hoarder/trpc/types/tags"; interface EditableTag { attachedBy: ZAttachedByEnum; - id?: string; - name: string; -} - -function TagAddInput({ addTag }: { addTag: (tag: string) => void }) { - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === "Enter") { - addTag(e.currentTarget.value); - e.currentTarget.value = ""; - } - }; - return ( - - ); -} - -function TagPill({ - tag, - deleteCB, -}: { - tag: { attachedBy: ZAttachedByEnum; id?: string; name: string }; - deleteCB: () => void; -}) { - const isAttachedByAI = tag.attachedBy == "ai"; - return ( -
- {isAttachedByAI && } -

{tag.name}

- -
- ); + value?: string; + label: string; } export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) { - const [tags, setTags] = useState>(new Map()); - useEffect(() => { - const m = new Map(); - for (const t of bookmark.tags) { - m.set(t.name, { attachedBy: t.attachedBy, id: t.id, name: t.name }); - } - setTags(m); - }, [bookmark.tags]); - const bookmarkInvalidationFunction = api.useUtils().bookmarks.getBookmark.invalidate; - const { mutate } = api.bookmarks.updateTags.useMutation({ - onSuccess: () => { - toast({ - description: "Tags has been updated!", - }); - bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); - // TODO(bug) Invalidate the tag views as well - }, - onError: () => { - toast({ - variant: "destructive", - title: "Something went wrong", - description: "There was a problem with your request.", - }); - }, - }); + const { mutate, isPending: isMutating } = + api.bookmarks.updateTags.useMutation({ + onSuccess: () => { + toast({ + description: "Tags has been updated!", + }); + bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); + // TODO(bug) Invalidate the tag views as well + }, + 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(); + + const onChange = ( + _option: readonly EditableTag[], + actionMeta: ActionMeta, + ) => { + switch (actionMeta.action) { + case "remove-value": { + if (actionMeta.removedValue.value) { + mutate({ + bookmarkId: bookmark.id, + attach: [], + detach: [{ tagId: actionMeta.removedValue.value }], + }); + } + break; + } + case "create-option": { + mutate({ + bookmarkId: bookmark.id, + attach: [{ tag: actionMeta.option.label }], + detach: [], + }); + break; + } + case "select-option": { + if (actionMeta.option) { + mutate({ + bookmarkId: bookmark.id, + attach: [ + { tag: actionMeta.option.label, tagId: actionMeta.option?.value }, + ], + detach: [], + }); + } + break; + } + } + }; return ( -
- {[...tags.values()].map((t) => ( - { - setTags((m) => { - const newMap = new Map(m); - newMap.delete(t.name); - if (t.id) { - mutate({ - bookmarkId: bookmark.id, - attach: [], - detach: [{ tagId: t.id }], - }); - } - return newMap; - }); - }} - /> - ))} -
- { - setTags((m) => { - if (m.has(val)) { - // Tag already exists - // Do nothing - return m; - } - const newMap = new Map(m); - newMap.set(val, { attachedBy: "human", name: val }); - mutate({ - bookmarkId: bookmark.id, - attach: [{ tag: val }], - detach: [], - }); - return newMap; - }); - }} - /> -
-
+ ({ + label: t.name, + value: t.id, + attachedBy: "human" as const, + })) ?? [] + } + value={bookmark.tags.map((t) => ({ + label: t.name, + value: t.id, + attachedBy: t.attachedBy, + }))} + isMulti + closeMenuOnSelect={false} + isClearable={false} + isLoading={isExistingTagsLoading || isMutating} + styles={{ + multiValueRemove: () => ({ + "background-color": "transparent", + }), + valueContainer: (styles) => ({ + ...styles, + padding: "0.5rem", + }), + }} + components={{ + MultiValueContainer: ({ children, data }) => ( +
+ {children} +
+ ), + MultiValueLabel: ({ children, data }) => ( +
+ {(data as { attachedBy: string }).attachedBy == "ai" && ( + + )} + {children} +
+ ), + }} + classNames={{ + multiValueRemove: () => "my-auto", + valueContainer: () => "gap-2", + menuList: () => "text-sm", + }} + /> ); } diff --git a/apps/web/package.json b/apps/web/package.json index bfe9a5a2..4b0b5e0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,7 @@ "react-hook-form": "^7.50.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", + "react-select": "^5.8.0", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", -- cgit v1.2.3-70-g09d2