From cddaefd9420507318d71f56355ff5a6648dcd951 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 19 Jan 2025 13:55:40 +0000 Subject: feat: Change webhooks to be configurable by users --- apps/web/app/settings/layout.tsx | 6 + apps/web/app/settings/webhooks/page.tsx | 5 + .../components/settings/WebhookEventSelector.tsx | 70 +++ apps/web/components/settings/WebhookSettings.tsx | 514 +++++++++++++++++++++ apps/web/components/ui/command.tsx | 154 ++++++ apps/web/lib/i18n/locales/en/translation.json | 18 + apps/web/package.json | 1 + 7 files changed, 768 insertions(+) create mode 100644 apps/web/app/settings/webhooks/page.tsx create mode 100644 apps/web/components/settings/WebhookEventSelector.tsx create mode 100644 apps/web/components/settings/WebhookSettings.tsx create mode 100644 apps/web/components/ui/command.tsx (limited to 'apps/web') diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index bbff68a9..909dfd9c 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -10,6 +10,7 @@ import { Rss, Sparkles, User, + Webhook, } from "lucide-react"; const settingsSidebarItems = ( @@ -54,6 +55,11 @@ const settingsSidebarItems = ( icon: , path: "/settings/broken-links", }, + { + name: t("settings.webhooks.webhooks"), + icon: , + path: "/settings/webhooks", + }, ]; export default async function SettingsLayout({ diff --git a/apps/web/app/settings/webhooks/page.tsx b/apps/web/app/settings/webhooks/page.tsx new file mode 100644 index 00000000..327d0d8f --- /dev/null +++ b/apps/web/app/settings/webhooks/page.tsx @@ -0,0 +1,5 @@ +import WebhookSettings from "@/components/settings/WebhookSettings"; + +export default function WebhookSettingsPage() { + return ; +} diff --git a/apps/web/components/settings/WebhookEventSelector.tsx b/apps/web/components/settings/WebhookEventSelector.tsx new file mode 100644 index 00000000..ef357754 --- /dev/null +++ b/apps/web/components/settings/WebhookEventSelector.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown } from "lucide-react"; + +import { + ZWebhookEvent, + zWebhookEventSchema, +} from "@hoarder/shared/types/webhooks"; + +export function WebhookEventSelector({ + value, + onChange, +}: { + value: ZWebhookEvent[]; + onChange: (value: ZWebhookEvent[]) => void; +}) { + return ( + + + + + + + + + No events found. + + {zWebhookEventSchema.options.map((eventType) => ( + { + const newEvents = value.includes(eventType) + ? value.filter((e) => e !== eventType) + : [...value, eventType]; + onChange(newEvents); + }} + > + {eventType} + {value?.includes(eventType) && ( + + )} + + ))} + + + + + + ); +} diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx new file mode 100644 index 00000000..4f7f72dc --- /dev/null +++ b/apps/web/components/settings/WebhookSettings.tsx @@ -0,0 +1,514 @@ +"use client"; + +import React from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Edit, KeyRound, Plus, Save, Trash2, X } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + zNewWebhookSchema, + zUpdateWebhookSchema, + ZWebhook, +} from "@hoarder/shared/types/webhooks"; + +import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { WebhookEventSelector } from "./WebhookEventSelector"; + +export function WebhooksEditorDialog() { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + const apiUtils = api.useUtils(); + + const form = useForm>({ + resolver: zodResolver(zNewWebhookSchema), + defaultValues: { + url: "", + events: [], + token: "", + }, + }); + + React.useEffect(() => { + if (open) { + form.reset(); + } + }, [open]); + + const { mutateAsync: createWebhook, isPending: isCreating } = + api.webhooks.create.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook has been created!", + }); + apiUtils.webhooks.list.invalidate(); + setOpen(false); + }, + }); + + return ( + + + + + + + {t("settings.webhooks.create_webhook")} + +
+ { + await createWebhook(value); + form.resetField("url"); + form.resetField("events"); + })} + > + { + return ( + + URL + + + + + + ); + }} + /> + ( + + {t("settings.webhooks.auth_token")} + + + + + + )} + /> + ( + + Events + + + + )} + /> + + + + + + + { + await createWebhook(value); + })} + loading={isCreating} + variant="default" + className="items-center" + > + + Add + + +
+
+ ); +} + +export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { + const { t } = useTranslation(); + const apiUtils = api.useUtils(); + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + if (open) { + form.reset({ + webhookId: webhook.id, + url: webhook.url, + events: webhook.events, + }); + } + }, [open]); + const { mutateAsync: updateWebhook, isPending: isUpdating } = + api.webhooks.update.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook has been updated!", + }); + setOpen(false); + apiUtils.webhooks.list.invalidate(); + }, + }); + const updateSchema = zUpdateWebhookSchema.required({ + events: true, + url: true, + }); + const form = useForm>({ + resolver: zodResolver(updateSchema), + defaultValues: { + webhookId: webhook.id, + url: webhook.url, + events: webhook.events, + }, + }); + return ( + + + + + + + {t("settings.webhooks.edit_webhook")} + + +
+ { + await updateWebhook(value); + })} + > + { + return ( + + + + + + + ); + }} + /> + { + return ( + + {t("common.url")} + + + + + + ); + }} + /> + ( + + {t("settings.webhooks.events.title")} + + + + )} + /> + + + + + + + { + await updateWebhook(value); + })} + type="submit" + className="items-center" + > + + {t("actions.save")} + + +
+
+ ); +} + +export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { + const { t } = useTranslation(); + const apiUtils = api.useUtils(); + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + if (open) { + form.reset({ + webhookId: webhook.id, + token: "", + }); + } + }, [open]); + + const updateSchema = zUpdateWebhookSchema + .pick({ + webhookId: true, + token: true, + }) + .required({ + token: true, + }); + + const form = useForm>({ + resolver: zodResolver(updateSchema), + defaultValues: { + webhookId: webhook.id, + token: "", + }, + }); + + const { mutateAsync: updateWebhook, isPending: isUpdating } = + api.webhooks.update.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook token has been updated!", + }); + setOpen(false); + apiUtils.webhooks.list.invalidate(); + }, + }); + + return ( + + + + + + + {t("settings.webhooks.edit_auth_token")} + +
+ { + await updateWebhook(value); + })} + > + ( + + + + + + )} + /> + ( + + {t("settings.webhooks.auth_token")} + + + + + + )} + /> + + + + + + + { + await updateWebhook({ + webhookId: value.webhookId, + token: null, + }); + })} + className="items-center" + > + + {t("actions.delete")} + + { + await updateWebhook(value); + })} + type="submit" + className="items-center" + > + + {t("actions.save")} + + +
+
+ ); +} + +export function WebhookRow({ webhook }: { webhook: ZWebhook }) { + const { t } = useTranslation(); + const apiUtils = api.useUtils(); + const { mutate: deleteWebhook, isPending: isDeleting } = + api.webhooks.delete.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook has been deleted!", + }); + apiUtils.webhooks.list.invalidate(); + }, + }); + + return ( + + {webhook.url} + {webhook.events.join(", ")} + {webhook.hasToken ? "*******" : } + + + + ( + deleteWebhook({ webhookId: webhook.id })} + className="items-center" + type="button" + > + + {t("actions.delete")} + + )} + > + + + + + ); +} + +export default function WebhookSettings() { + const { t } = useTranslation(); + const { data: webhooks, isLoading } = api.webhooks.list.useQuery(); + return ( +
+
+
+ + {t("settings.webhooks.webhooks")} + + +
+

+ {t("settings.webhooks.description")} +

+ {isLoading && } + {webhooks && webhooks.webhooks.length == 0 && ( +

+ You don't have any webhooks configured yet. +

+ )} + {webhooks && webhooks.webhooks.length > 0 && ( + + + + {t("common.url")} + {t("settings.webhooks.events.title")} + {t("settings.webhooks.auth_token")} + {t("common.actions")} + + + + {webhooks.webhooks.map((webhook) => ( + + ))} + +
+ )} +
+
+ ); +} diff --git a/apps/web/components/ui/command.tsx b/apps/web/components/ui/command.tsx new file mode 100644 index 00000000..d6ed6ba1 --- /dev/null +++ b/apps/web/components/ui/command.tsx @@ -0,0 +1,154 @@ +"use client"; + +import type { DialogProps } from "@radix-ui/react-dialog"; +import * as React from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + // https://github.com/shadcn-ui/ui/issues/3366 + // eslint-disable-next-line react/no-unknown-property +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 1e9f8e4d..eec48ec4 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -109,6 +109,24 @@ "rss_subscriptions": "RSS Subscriptions", "add_a_subscription": "Add a Subscription" }, + "webhooks": { + "webhooks": "Webhooks", + "description": "You can use webhooks to trigger actions when bookmarks are created, changed or crawled.", + "events": { + "title": "Events", + "crawled": "Crawled", + "created": "Created", + "edited": "Edited" + }, + "auth_token": "Auth Token", + "add_auth_token": "Add Auth Token", + "edit_auth_token": "Edit Auth Token", + "create_webhook": "Create Webhook", + "delete_webhook": "Delete Webhook", + "delete_webhook_confirmation": "Are you sure you want to delete this webhook?", + "edit_webhook": "Edit Webhook", + "webhook_url": "Webhook URL" + }, "import": { "import_export": "Import / Export", "import_export_bookmarks": "Import / Export Bookmarks", diff --git a/apps/web/package.json b/apps/web/package.json index d2cbd589..f2c3eec7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "cheerio": "^1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "1.0.0", "csv-parse": "^5.5.6", "dayjs": "^1.11.10", "drizzle-orm": "^0.38.3", -- cgit v1.2.3-70-g09d2