Skip to content

Commit

Permalink
feat: Add support for smart lists (#802)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
MohamedBassem and orthdron authored Jan 2, 2025
1 parent 5df0258 commit 5ecdc36
Show file tree
Hide file tree
Showing 26 changed files with 2,045 additions and 100 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ README.md
**/*.db
**/.env*
.git
./data
12 changes: 10 additions & 2 deletions apps/cli/src/commands/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/app/api/v1/lists/[listId]/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 };
},
Expand Down
16 changes: 10 additions & 6 deletions apps/web/app/dashboard/lists/[listId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand All @@ -22,11 +24,13 @@ export default async function ListPage({
}

return (
<Bookmarks
query={{ listId: list.id }}
showDivider={true}
showEditorCard={true}
header={<ListHeader initialData={list} />}
/>
<BookmarkListContextProvider list={list}>
<Bookmarks
query={{ listId: list.id }}
showDivider={true}
showEditorCard={list.type === "manual"}
header={<ListHeader initialData={list} />}
/>
</BookmarkListContextProvider>
);
}
32 changes: 18 additions & 14 deletions apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -210,20 +212,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
<span>{t("actions.manage_lists")}</span>
</DropdownMenuItem>

{listId && (
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
removeFromListMutator.mutate({
listId,
bookmarkId: bookmark.id,
})
}
>
<ListX className="mr-2 size-4" />
<span>{t("actions.remove_from_list")}</span>
</DropdownMenuItem>
)}
{listId &&
withinListContext &&
withinListContext.type === "manual" && (
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
removeFromListMutator.mutate({
listId,
bookmarkId: bookmark.id,
})
}
>
<ListX className="mr-2 size-4" />
<span>{t("actions.remove_from_list")}</span>
</DropdownMenuItem>
)}

{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
Expand Down
119 changes: 96 additions & 23 deletions apps/web/components/dashboard/lists/EditListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import data from "@emoji-mart/data";
Expand All @@ -38,21 +45,24 @@ import {
useCreateBookmarkList,
useEditBookmarkList,
} from "@hoarder/shared-react/hooks/lists";
import { ZBookmarkList } from "@hoarder/shared/types/lists";
import {
ZBookmarkList,
zNewBookmarkListSchema,
} from "@hoarder/shared/types/lists";

import { BookmarkListSelector } from "./BookmarkListSelector";

export function EditListModal({
open: userOpen,
setOpen: userSetOpen,
list,
parent,
prefill,
children,
}: {
open?: boolean;
setOpen?: (v: boolean) => void;
list?: ZBookmarkList;
parent?: ZBookmarkList;
prefill?: Partial<Omit<ZBookmarkList, "id">>;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
Expand All @@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<z.infer<typeof zNewBookmarkListSchema>>({
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] = [
Expand All @@ -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]);

Expand Down Expand Up @@ -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<typeof formSchema>) => {
value.parentId = value.parentId === "" ? null : value.parentId;
isEdit ? editList({ ...value, listId: list.id }) : createList(value);
});
const onSubmit = form.handleSubmit(
(value: z.infer<typeof zNewBookmarkListSchema>) => {
value.parentId = value.parentId === "" ? null : value.parentId;
value.query = value.type === "smart" ? value.query : undefined;
isEdit ? editList({ ...value, listId: list.id }) : createList(value);
},
);

return (
<Dialog
Expand All @@ -176,7 +195,9 @@ export function EditListModal({
<Form {...form}>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit" : "New"} List</DialogTitle>
<DialogTitle>
{isEdit ? t("lists.edit_list") : t("lists.new_list")}
</DialogTitle>
</DialogHeader>
<div className="flex w-full gap-2 py-4">
<FormField
Expand Down Expand Up @@ -232,15 +253,15 @@ export function EditListModal({
render={({ field }) => {
return (
<FormItem className="grow pb-4">
<FormLabel>Parent</FormLabel>
<FormLabel>{t("lists.parent_list")}</FormLabel>
<div className="flex items-center gap-1">
<FormControl>
<BookmarkListSelector
// Hide the current list from the list of parents
hideSubtreeOf={list ? list.id : undefined}
value={field.value}
onChange={field.onChange}
placeholder={"No Parent"}
placeholder={t("lists.no_parent")}
/>
</FormControl>
<Button
Expand All @@ -258,6 +279,58 @@ export function EditListModal({
);
}}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => {
return (
<FormItem className="grow pb-4">
<FormLabel>{t("lists.list_type")}</FormLabel>
<FormControl>
<Select
disabled={isEdit}
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
{t("lists.manual_list")}
</SelectItem>
<SelectItem value="smart">
{t("lists.smart_list")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
{listType === "smart" && (
<FormField
control={form.control}
name="query"
render={({ field }) => {
return (
<FormItem className="grow pb-4">
<FormLabel>{t("lists.search_query")}</FormLabel>
<FormControl>
<Input
value={field.value}
onChange={field.onChange}
placeholder={t("lists.search_query")}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Expand Down
38 changes: 32 additions & 6 deletions apps/web/components/dashboard/lists/ListHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
"use client";

import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
import { useTranslation } from "@/lib/i18n/client";
import { MoreHorizontal, SearchIcon } from "lucide-react";

import { api } from "@hoarder/shared-react/trpc";
import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";
import { ZBookmarkList } from "@hoarder/shared/types/lists";

import QueryExplainerTooltip from "../search/QueryExplainerTooltip";
import { ListOptions } from "./ListOptions";

export default function ListHeader({
initialData,
}: {
initialData: ZBookmarkList & { bookmarks: string[] };
initialData: ZBookmarkList;
}) {
const { t } = useTranslation();
const router = useRouter();
const { data: list, error } = api.lists.get.useQuery(
{
Expand All @@ -24,6 +29,13 @@ export default function ListHeader({
},
);

const parsedQuery = useMemo(() => {
if (!list.query) {
return null;
}
return parseSearchQuery(list.query);
}, [list.query]);

if (error) {
// This is usually exercised during list deletions.
if (error.data?.code == "NOT_FOUND") {
Expand All @@ -33,10 +45,24 @@ export default function ListHeader({

return (
<div className="flex items-center justify-between">
<span className="text-2xl">
{list.icon} {list.name}
</span>
<div className="flex">
<div className="flex items-center gap-2">
<span className="text-2xl">
{list.icon} {list.name}
</span>
</div>
<div className="flex items-center">
{parsedQuery && (
<QueryExplainerTooltip
header={
<div className="flex items-center justify-center gap-1">
<SearchIcon className="size-3" />
<span className="text-sm">{t("lists.smart_list")}</span>
</div>
}
parsedSearchQuery={parsedQuery}
className="size-6 stroke-foreground"
/>
)}
<ListOptions list={list}>
<Button variant="ghost">
<MoreHorizontal />
Expand Down
Loading

0 comments on commit 5ecdc36

Please sign in to comment.