aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard/lists
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-01-02 13:00:58 +0200
committerGitHub <noreply@github.com>2025-01-02 13:00:58 +0200
commit5ecdc36b7d60aa66b49e01e9fec8ba61ad537376 (patch)
tree57577822bb104b95900ba577a265fb4f8cf70b78 /apps/web/components/dashboard/lists
parent5df0258b2cd884347eabfa866d7e7fbc7225cdb3 (diff)
downloadkarakeep-5ecdc36b7d60aa66b49e01e9fec8ba61ad537376.tar.zst
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>
Diffstat (limited to 'apps/web/components/dashboard/lists')
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx119
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx38
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx4
3 files changed, 131 insertions, 30 deletions
diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx
index d66d7096..98bb19be 100644
--- a/apps/web/components/dashboard/lists/EditListModal.tsx
+++ b/apps/web/components/dashboard/lists/EditListModal.tsx
@@ -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";
@@ -38,7 +45,10 @@ 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";
@@ -46,13 +56,13 @@ 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();
@@ -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] = [
@@ -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<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
@@ -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
@@ -232,7 +253,7 @@ 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
@@ -240,7 +261,7 @@ export function EditListModal({
hideSubtreeOf={list ? list.id : undefined}
value={field.value}
onChange={field.onChange}
- placeholder={"No Parent"}
+ placeholder={t("lists.no_parent")}
/>
</FormControl>
<Button
@@ -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">
diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx
index a6780e1e..b8bfb4ad 100644
--- a/apps/web/components/dashboard/lists/ListHeader.tsx
+++ b/apps/web/components/dashboard/lists/ListHeader.tsx
@@ -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(
{
@@ -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") {
@@ -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 />
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index e663a2e0..a7217954 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -33,7 +33,9 @@ export function ListOptions({
<EditListModal
open={newNestedListModalOpen}
setOpen={setNewNestedListModalOpen}
- parent={list}
+ prefill={{
+ parentId: list.id,
+ }}
/>
<EditListModal
open={editModalOpen}