From 5ecdc36b7d60aa66b49e01e9fec8ba61ad537376 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Thu, 2 Jan 2025 13:00:58 +0200 Subject: feat: Add support for smart lists (#802) * feat: Add support for smart lists * i18n * Fix update list endpoint * Add a test for smart lists * Add header to the query explainer * Hide remove from lists in the smart context list * Add proper validation to list form --------- Co-authored-by: Deepak Kapoor <41769111+orthdron@users.noreply.github.com> --- apps/cli/src/commands/lists.ts | 12 ++- apps/web/app/api/v1/lists/[listId]/route.ts | 6 +- apps/web/app/dashboard/lists/[listId]/page.tsx | 16 +-- .../dashboard/bookmarks/BookmarkOptions.tsx | 32 +++--- .../components/dashboard/lists/EditListModal.tsx | 119 +++++++++++++++++---- apps/web/components/dashboard/lists/ListHeader.tsx | 38 +++++-- .../web/components/dashboard/lists/ListOptions.tsx | 4 +- .../dashboard/search/QueryExplainerTooltip.tsx | 3 + .../components/dashboard/search/SearchInput.tsx | 24 ++++- apps/web/lib/i18n/locales/en/translation.json | 9 +- 10 files changed, 206 insertions(+), 57 deletions(-) (limited to 'apps') diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts index 4b157cdf..57b6d948 100644 --- a/apps/cli/src/commands/lists.ts +++ b/apps/cli/src/commands/lists.ts @@ -89,9 +89,17 @@ listsCmd .action(async (opts) => { const api = getAPIClient(); try { - const results = await api.lists.get.query({ listId: opts.list }); + let resp = await api.bookmarks.getBookmarks.query({ listId: opts.list }); + let results: string[] = resp.bookmarks.map((b) => b.id); + while (resp.nextCursor) { + resp = await api.bookmarks.getBookmarks.query({ + listId: opts.list, + cursor: resp.nextCursor, + }); + results = [...results, ...resp.bookmarks.map((b) => b.id)]; + } - printObject(results.bookmarks); + printObject(results); } catch (error) { printErrorMessageWithReason( "Failed to get the ids of the bookmarks in the list", diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts index 69c99fda..3fd0a32d 100644 --- a/apps/web/app/api/v1/lists/[listId]/route.ts +++ b/apps/web/app/api/v1/lists/[listId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { buildHandler } from "@/app/api/v1/utils/handler"; -import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists"; +import { zEditBookmarkListSchema } from "@hoarder/shared/types/lists"; export const dynamic = "force-dynamic"; @@ -28,11 +28,11 @@ export const PATCH = ( ) => buildHandler({ req, - bodySchema: zNewBookmarkListSchema.partial(), + bodySchema: zEditBookmarkListSchema.omit({ listId: true }), handler: async ({ api, body }) => { const list = await api.lists.edit({ - listId: params.listId, ...body!, + listId: params.listId, }); return { status: 200, resp: list }; }, diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx index f8c5e0b6..159730a1 100644 --- a/apps/web/app/dashboard/lists/[listId]/page.tsx +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -4,6 +4,8 @@ import ListHeader from "@/components/dashboard/lists/ListHeader"; import { api } from "@/server/api/client"; import { TRPCError } from "@trpc/server"; +import { BookmarkListContextProvider } from "@hoarder/shared-react/hooks/bookmark-list-context"; + export default async function ListPage({ params, }: { @@ -22,11 +24,13 @@ export default async function ListPage({ } return ( - } - /> + + } + /> + ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index e9e5834b..c37c6417 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -33,6 +33,7 @@ import { } from "@hoarder/shared-react/hooks//bookmarks"; import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists"; import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context"; +import { useBookmarkListContext } from "@hoarder/shared-react/hooks/bookmark-list-context"; import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; @@ -58,6 +59,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const { listId } = useBookmarkGridContext() ?? {}; + const withinListContext = useBookmarkListContext(); const onError = () => { toast({ @@ -210,20 +212,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { {t("actions.manage_lists")} - {listId && ( - - removeFromListMutator.mutate({ - listId, - bookmarkId: bookmark.id, - }) - } - > - - {t("actions.remove_from_list")} - - )} + {listId && + withinListContext && + withinListContext.type === "manual" && ( + + removeFromListMutator.mutate({ + listId, + bookmarkId: bookmark.id, + }) + } + > + + {t("actions.remove_from_list")} + + )} {bookmark.content.type === BookmarkTypes.LINK && ( void; list?: ZBookmarkList; - parent?: ZBookmarkList; + prefill?: Partial>; children?: React.ReactNode; }) { const { t } = useTranslation(); @@ -64,17 +74,14 @@ export function EditListModal({ throw new Error("You must provide both open and setOpen or neither"); } const [customOpen, customSetOpen] = useState(false); - const formSchema = z.object({ - name: z.string(), - icon: z.string(), - parentId: z.string().nullish(), - }); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(zNewBookmarkListSchema), defaultValues: { - name: list?.name ?? "", - icon: list?.icon ?? "🚀", - parentId: list?.parentId ?? parent?.id, + name: list?.name ?? prefill?.name ?? "", + icon: list?.icon ?? prefill?.icon ?? "🚀", + parentId: list?.parentId ?? prefill?.parentId, + type: list?.type ?? prefill?.type ?? "manual", + query: list?.query ?? prefill?.query ?? undefined, }, }); const [open, setOpen] = [ @@ -84,9 +91,11 @@ export function EditListModal({ useEffect(() => { form.reset({ - name: list?.name ?? "", - icon: list?.icon ?? "🚀", - parentId: list?.parentId ?? parent?.id, + name: list?.name ?? prefill?.name ?? "", + icon: list?.icon ?? prefill?.icon ?? "🚀", + parentId: list?.parentId ?? prefill?.parentId, + type: list?.type ?? prefill?.type ?? "manual", + query: list?.query ?? prefill?.query ?? undefined, }); }, [open]); @@ -154,14 +163,24 @@ export function EditListModal({ } }, }); + const listType = form.watch("type"); + + useEffect(() => { + if (listType !== "smart") { + form.resetField("query"); + } + }, [listType]); const isEdit = !!list; const isPending = isCreating || isEditing; - const onSubmit = form.handleSubmit((value: z.infer) => { - value.parentId = value.parentId === "" ? null : value.parentId; - isEdit ? editList({ ...value, listId: list.id }) : createList(value); - }); + const onSubmit = form.handleSubmit( + (value: z.infer) => { + value.parentId = value.parentId === "" ? null : value.parentId; + value.query = value.type === "smart" ? value.query : undefined; + isEdit ? editList({ ...value, listId: list.id }) : createList(value); + }, + ); return (
- {isEdit ? "Edit" : "New"} List + + {isEdit ? t("lists.edit_list") : t("lists.new_list")} +
{ return ( - Parent + {t("lists.parent_list")}