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 --- .../components/settings/WebhookEventSelector.tsx | 70 +++ apps/web/components/settings/WebhookSettings.tsx | 514 +++++++++++++++++++++ apps/web/components/ui/command.tsx | 154 ++++++ 3 files changed, 738 insertions(+) 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/components') 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, +}; -- cgit v1.2.3-70-g09d2