Skip to content

Commit 5ecdc36

Browse files
feat: Add support for smart lists (hoarder-app#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 <[email protected]>
1 parent 5df0258 commit 5ecdc36

File tree

26 files changed

+2045
-100
lines changed

26 files changed

+2045
-100
lines changed

.dockerignore

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ README.md
77
**/*.db
88
**/.env*
99
.git
10+
./data

apps/cli/src/commands/lists.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,17 @@ listsCmd
8989
.action(async (opts) => {
9090
const api = getAPIClient();
9191
try {
92-
const results = await api.lists.get.query({ listId: opts.list });
92+
let resp = await api.bookmarks.getBookmarks.query({ listId: opts.list });
93+
let results: string[] = resp.bookmarks.map((b) => b.id);
94+
while (resp.nextCursor) {
95+
resp = await api.bookmarks.getBookmarks.query({
96+
listId: opts.list,
97+
cursor: resp.nextCursor,
98+
});
99+
results = [...results, ...resp.bookmarks.map((b) => b.id)];
100+
}
93101

94-
printObject(results.bookmarks);
102+
printObject(results);
95103
} catch (error) {
96104
printErrorMessageWithReason(
97105
"Failed to get the ids of the bookmarks in the list",

apps/web/app/api/v1/lists/[listId]/route.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest } from "next/server";
22
import { buildHandler } from "@/app/api/v1/utils/handler";
33

4-
import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists";
4+
import { zEditBookmarkListSchema } from "@hoarder/shared/types/lists";
55

66
export const dynamic = "force-dynamic";
77

@@ -28,11 +28,11 @@ export const PATCH = (
2828
) =>
2929
buildHandler({
3030
req,
31-
bodySchema: zNewBookmarkListSchema.partial(),
31+
bodySchema: zEditBookmarkListSchema.omit({ listId: true }),
3232
handler: async ({ api, body }) => {
3333
const list = await api.lists.edit({
34-
listId: params.listId,
3534
...body!,
35+
listId: params.listId,
3636
});
3737
return { status: 200, resp: list };
3838
},

apps/web/app/dashboard/lists/[listId]/page.tsx

+10-6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import ListHeader from "@/components/dashboard/lists/ListHeader";
44
import { api } from "@/server/api/client";
55
import { TRPCError } from "@trpc/server";
66

7+
import { BookmarkListContextProvider } from "@hoarder/shared-react/hooks/bookmark-list-context";
8+
79
export default async function ListPage({
810
params,
911
}: {
@@ -22,11 +24,13 @@ export default async function ListPage({
2224
}
2325

2426
return (
25-
<Bookmarks
26-
query={{ listId: list.id }}
27-
showDivider={true}
28-
showEditorCard={true}
29-
header={<ListHeader initialData={list} />}
30-
/>
27+
<BookmarkListContextProvider list={list}>
28+
<Bookmarks
29+
query={{ listId: list.id }}
30+
showDivider={true}
31+
showEditorCard={list.type === "manual"}
32+
header={<ListHeader initialData={list} />}
33+
/>
34+
</BookmarkListContextProvider>
3135
);
3236
}

apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx

+18-14
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from "@hoarder/shared-react/hooks//bookmarks";
3434
import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists";
3535
import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context";
36+
import { useBookmarkListContext } from "@hoarder/shared-react/hooks/bookmark-list-context";
3637
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
3738

3839
import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
@@ -58,6 +59,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
5859
const [isTextEditorOpen, setTextEditorOpen] = useState(false);
5960

6061
const { listId } = useBookmarkGridContext() ?? {};
62+
const withinListContext = useBookmarkListContext();
6163

6264
const onError = () => {
6365
toast({
@@ -210,20 +212,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
210212
<span>{t("actions.manage_lists")}</span>
211213
</DropdownMenuItem>
212214

213-
{listId && (
214-
<DropdownMenuItem
215-
disabled={demoMode}
216-
onClick={() =>
217-
removeFromListMutator.mutate({
218-
listId,
219-
bookmarkId: bookmark.id,
220-
})
221-
}
222-
>
223-
<ListX className="mr-2 size-4" />
224-
<span>{t("actions.remove_from_list")}</span>
225-
</DropdownMenuItem>
226-
)}
215+
{listId &&
216+
withinListContext &&
217+
withinListContext.type === "manual" && (
218+
<DropdownMenuItem
219+
disabled={demoMode}
220+
onClick={() =>
221+
removeFromListMutator.mutate({
222+
listId,
223+
bookmarkId: bookmark.id,
224+
})
225+
}
226+
>
227+
<ListX className="mr-2 size-4" />
228+
<span>{t("actions.remove_from_list")}</span>
229+
</DropdownMenuItem>
230+
)}
227231

228232
{bookmark.content.type === BookmarkTypes.LINK && (
229233
<DropdownMenuItem

apps/web/components/dashboard/lists/EditListModal.tsx

+96-23
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ import {
2525
PopoverContent,
2626
PopoverTrigger,
2727
} from "@/components/ui/popover";
28+
import {
29+
Select,
30+
SelectContent,
31+
SelectItem,
32+
SelectTrigger,
33+
SelectValue,
34+
} from "@/components/ui/select";
2835
import { toast } from "@/components/ui/use-toast";
2936
import { useTranslation } from "@/lib/i18n/client";
3037
import data from "@emoji-mart/data";
@@ -38,21 +45,24 @@ import {
3845
useCreateBookmarkList,
3946
useEditBookmarkList,
4047
} from "@hoarder/shared-react/hooks/lists";
41-
import { ZBookmarkList } from "@hoarder/shared/types/lists";
48+
import {
49+
ZBookmarkList,
50+
zNewBookmarkListSchema,
51+
} from "@hoarder/shared/types/lists";
4252

4353
import { BookmarkListSelector } from "./BookmarkListSelector";
4454

4555
export function EditListModal({
4656
open: userOpen,
4757
setOpen: userSetOpen,
4858
list,
49-
parent,
59+
prefill,
5060
children,
5161
}: {
5262
open?: boolean;
5363
setOpen?: (v: boolean) => void;
5464
list?: ZBookmarkList;
55-
parent?: ZBookmarkList;
65+
prefill?: Partial<Omit<ZBookmarkList, "id">>;
5666
children?: React.ReactNode;
5767
}) {
5868
const { t } = useTranslation();
@@ -64,17 +74,14 @@ export function EditListModal({
6474
throw new Error("You must provide both open and setOpen or neither");
6575
}
6676
const [customOpen, customSetOpen] = useState(false);
67-
const formSchema = z.object({
68-
name: z.string(),
69-
icon: z.string(),
70-
parentId: z.string().nullish(),
71-
});
72-
const form = useForm<z.infer<typeof formSchema>>({
73-
resolver: zodResolver(formSchema),
77+
const form = useForm<z.infer<typeof zNewBookmarkListSchema>>({
78+
resolver: zodResolver(zNewBookmarkListSchema),
7479
defaultValues: {
75-
name: list?.name ?? "",
76-
icon: list?.icon ?? "🚀",
77-
parentId: list?.parentId ?? parent?.id,
80+
name: list?.name ?? prefill?.name ?? "",
81+
icon: list?.icon ?? prefill?.icon ?? "🚀",
82+
parentId: list?.parentId ?? prefill?.parentId,
83+
type: list?.type ?? prefill?.type ?? "manual",
84+
query: list?.query ?? prefill?.query ?? undefined,
7885
},
7986
});
8087
const [open, setOpen] = [
@@ -84,9 +91,11 @@ export function EditListModal({
8491

8592
useEffect(() => {
8693
form.reset({
87-
name: list?.name ?? "",
88-
icon: list?.icon ?? "🚀",
89-
parentId: list?.parentId ?? parent?.id,
94+
name: list?.name ?? prefill?.name ?? "",
95+
icon: list?.icon ?? prefill?.icon ?? "🚀",
96+
parentId: list?.parentId ?? prefill?.parentId,
97+
type: list?.type ?? prefill?.type ?? "manual",
98+
query: list?.query ?? prefill?.query ?? undefined,
9099
});
91100
}, [open]);
92101

@@ -154,14 +163,24 @@ export function EditListModal({
154163
}
155164
},
156165
});
166+
const listType = form.watch("type");
167+
168+
useEffect(() => {
169+
if (listType !== "smart") {
170+
form.resetField("query");
171+
}
172+
}, [listType]);
157173

158174
const isEdit = !!list;
159175
const isPending = isCreating || isEditing;
160176

161-
const onSubmit = form.handleSubmit((value: z.infer<typeof formSchema>) => {
162-
value.parentId = value.parentId === "" ? null : value.parentId;
163-
isEdit ? editList({ ...value, listId: list.id }) : createList(value);
164-
});
177+
const onSubmit = form.handleSubmit(
178+
(value: z.infer<typeof zNewBookmarkListSchema>) => {
179+
value.parentId = value.parentId === "" ? null : value.parentId;
180+
value.query = value.type === "smart" ? value.query : undefined;
181+
isEdit ? editList({ ...value, listId: list.id }) : createList(value);
182+
},
183+
);
165184

166185
return (
167186
<Dialog
@@ -176,7 +195,9 @@ export function EditListModal({
176195
<Form {...form}>
177196
<form onSubmit={onSubmit}>
178197
<DialogHeader>
179-
<DialogTitle>{isEdit ? "Edit" : "New"} List</DialogTitle>
198+
<DialogTitle>
199+
{isEdit ? t("lists.edit_list") : t("lists.new_list")}
200+
</DialogTitle>
180201
</DialogHeader>
181202
<div className="flex w-full gap-2 py-4">
182203
<FormField
@@ -232,15 +253,15 @@ export function EditListModal({
232253
render={({ field }) => {
233254
return (
234255
<FormItem className="grow pb-4">
235-
<FormLabel>Parent</FormLabel>
256+
<FormLabel>{t("lists.parent_list")}</FormLabel>
236257
<div className="flex items-center gap-1">
237258
<FormControl>
238259
<BookmarkListSelector
239260
// Hide the current list from the list of parents
240261
hideSubtreeOf={list ? list.id : undefined}
241262
value={field.value}
242263
onChange={field.onChange}
243-
placeholder={"No Parent"}
264+
placeholder={t("lists.no_parent")}
244265
/>
245266
</FormControl>
246267
<Button
@@ -258,6 +279,58 @@ export function EditListModal({
258279
);
259280
}}
260281
/>
282+
<FormField
283+
control={form.control}
284+
name="type"
285+
render={({ field }) => {
286+
return (
287+
<FormItem className="grow pb-4">
288+
<FormLabel>{t("lists.list_type")}</FormLabel>
289+
<FormControl>
290+
<Select
291+
disabled={isEdit}
292+
onValueChange={field.onChange}
293+
value={field.value}
294+
>
295+
<SelectTrigger className="w-full">
296+
<SelectValue />
297+
</SelectTrigger>
298+
<SelectContent>
299+
<SelectItem value="manual">
300+
{t("lists.manual_list")}
301+
</SelectItem>
302+
<SelectItem value="smart">
303+
{t("lists.smart_list")}
304+
</SelectItem>
305+
</SelectContent>
306+
</Select>
307+
</FormControl>
308+
<FormMessage />
309+
</FormItem>
310+
);
311+
}}
312+
/>
313+
{listType === "smart" && (
314+
<FormField
315+
control={form.control}
316+
name="query"
317+
render={({ field }) => {
318+
return (
319+
<FormItem className="grow pb-4">
320+
<FormLabel>{t("lists.search_query")}</FormLabel>
321+
<FormControl>
322+
<Input
323+
value={field.value}
324+
onChange={field.onChange}
325+
placeholder={t("lists.search_query")}
326+
/>
327+
</FormControl>
328+
<FormMessage />
329+
</FormItem>
330+
);
331+
}}
332+
/>
333+
)}
261334
<DialogFooter className="sm:justify-end">
262335
<DialogClose asChild>
263336
<Button type="button" variant="secondary">

apps/web/components/dashboard/lists/ListHeader.tsx

+32-6
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
"use client";
22

3+
import { useMemo } from "react";
34
import { useRouter } from "next/navigation";
45
import { Button } from "@/components/ui/button";
5-
import { MoreHorizontal } from "lucide-react";
6+
import { useTranslation } from "@/lib/i18n/client";
7+
import { MoreHorizontal, SearchIcon } from "lucide-react";
68

79
import { api } from "@hoarder/shared-react/trpc";
10+
import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";
811
import { ZBookmarkList } from "@hoarder/shared/types/lists";
912

13+
import QueryExplainerTooltip from "../search/QueryExplainerTooltip";
1014
import { ListOptions } from "./ListOptions";
1115

1216
export default function ListHeader({
1317
initialData,
1418
}: {
15-
initialData: ZBookmarkList & { bookmarks: string[] };
19+
initialData: ZBookmarkList;
1620
}) {
21+
const { t } = useTranslation();
1722
const router = useRouter();
1823
const { data: list, error } = api.lists.get.useQuery(
1924
{
@@ -24,6 +29,13 @@ export default function ListHeader({
2429
},
2530
);
2631

32+
const parsedQuery = useMemo(() => {
33+
if (!list.query) {
34+
return null;
35+
}
36+
return parseSearchQuery(list.query);
37+
}, [list.query]);
38+
2739
if (error) {
2840
// This is usually exercised during list deletions.
2941
if (error.data?.code == "NOT_FOUND") {
@@ -33,10 +45,24 @@ export default function ListHeader({
3345

3446
return (
3547
<div className="flex items-center justify-between">
36-
<span className="text-2xl">
37-
{list.icon} {list.name}
38-
</span>
39-
<div className="flex">
48+
<div className="flex items-center gap-2">
49+
<span className="text-2xl">
50+
{list.icon} {list.name}
51+
</span>
52+
</div>
53+
<div className="flex items-center">
54+
{parsedQuery && (
55+
<QueryExplainerTooltip
56+
header={
57+
<div className="flex items-center justify-center gap-1">
58+
<SearchIcon className="size-3" />
59+
<span className="text-sm">{t("lists.smart_list")}</span>
60+
</div>
61+
}
62+
parsedSearchQuery={parsedQuery}
63+
className="size-6 stroke-foreground"
64+
/>
65+
)}
4066
<ListOptions list={list}>
4167
<Button variant="ghost">
4268
<MoreHorizontal />

0 commit comments

Comments
 (0)