Openstatus www.openstatus.dev
at main 211 lines 6.5 kB view raw
1"use client"; 2 3import type * as React from "react"; 4import { useState, useTransition } from "react"; 5 6import { 7 Check, 8 Copy, 9 type LucideIcon, 10 MoreHorizontal, 11 Trash2, 12} from "lucide-react"; 13 14import { 15 AlertDialog, 16 AlertDialogAction, 17 AlertDialogCancel, 18 AlertDialogContent, 19 AlertDialogDescription, 20 AlertDialogFooter, 21 AlertDialogHeader, 22 AlertDialogTitle, 23 AlertDialogTrigger, 24} from "@/components/ui/alert-dialog"; 25import { Button } from "@/components/ui/button"; 26import { 27 DropdownMenu, 28 DropdownMenuContent, 29 DropdownMenuGroup, 30 DropdownMenuItem, 31 DropdownMenuLabel, 32 DropdownMenuSeparator, 33 DropdownMenuTrigger, 34} from "@/components/ui/dropdown-menu"; 35import { Input } from "@/components/ui/input"; 36import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 37import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; 38import { isTRPCClientError } from "@trpc/client"; 39import { toast } from "sonner"; 40 41interface QuickActionsProps extends React.ComponentProps<typeof Button> { 42 align?: DropdownMenuContentProps["align"]; 43 side?: DropdownMenuContentProps["side"]; 44 actions?: { 45 id: string; 46 label: string; 47 icon: LucideIcon; 48 variant: "default" | "destructive"; 49 onClick?: () => Promise<void> | void; 50 }[]; 51 deleteAction?: { 52 /** 53 * The value that must be typed to confirm deletion. Also used in the dialog title. 54 */ 55 confirmationValue: string; 56 submitAction?: () => Promise<void>; 57 }; 58} 59 60export function QuickActions({ 61 align = "end", 62 side, 63 className, 64 actions, 65 deleteAction, 66 children, 67 ...props 68}: QuickActionsProps) { 69 const [value, setValue] = useState(""); 70 const [isPending, startTransition] = useTransition(); 71 const [open, setOpen] = useState(false); 72 const { copy, isCopied } = useCopyToClipboard(); 73 74 const handleDelete = async () => { 75 startTransition(async () => { 76 if (!deleteAction?.submitAction) return; 77 const promise = deleteAction.submitAction(); 78 toast.promise(promise, { 79 loading: "Deleting...", 80 success: "Deleted", 81 error: (error) => { 82 if (isTRPCClientError(error)) { 83 return error.message; 84 } 85 return "Failed to delete"; 86 }, 87 }); 88 try { 89 await promise; 90 } catch (error) { 91 console.error("Failed to delete:", error); 92 } finally { 93 setOpen(false); 94 } 95 }); 96 }; 97 98 return ( 99 <AlertDialog open={open} onOpenChange={setOpen}> 100 <DropdownMenu> 101 <DropdownMenuTrigger asChild> 102 {children ?? ( 103 <Button 104 variant="ghost" 105 size="icon" 106 className={className ?? "h-7 w-7 data-[state=open]:bg-accent"} 107 {...props} 108 > 109 <MoreHorizontal /> 110 </Button> 111 )} 112 </DropdownMenuTrigger> 113 <DropdownMenuContent align={align} side={side} className="w-36"> 114 <DropdownMenuLabel className="sr-only"> 115 Quick Actions 116 </DropdownMenuLabel> 117 {actions 118 ?.filter((item) => item.id !== "delete") 119 .map((item) => ( 120 <DropdownMenuGroup key={item.id}> 121 <DropdownMenuItem 122 variant={item.variant} 123 disabled={!item.onClick} 124 onClick={(e) => { 125 e.stopPropagation(); 126 item.onClick?.(); 127 }} 128 > 129 <item.icon className="text-muted-foreground" /> 130 <span className="truncate">{item.label}</span> 131 </DropdownMenuItem> 132 </DropdownMenuGroup> 133 ))} 134 {deleteAction && ( 135 <> 136 {/* NOTE: add a separator only if actions exist */} 137 {actions?.length ? <DropdownMenuSeparator /> : null} 138 <AlertDialogTrigger asChild> 139 <DropdownMenuItem variant="destructive"> 140 <Trash2 className="text-muted-foreground" /> 141 Delete 142 </DropdownMenuItem> 143 </AlertDialogTrigger> 144 </> 145 )} 146 </DropdownMenuContent> 147 </DropdownMenu> 148 <AlertDialogContent 149 onCloseAutoFocus={(event) => { 150 // NOTE: bug where the body is not clickable after closing the alert dialog 151 event.preventDefault(); 152 document.body.style.pointerEvents = ""; 153 }} 154 > 155 <AlertDialogHeader> 156 <AlertDialogTitle> 157 Are you sure about deleting `{deleteAction?.confirmationValue}`? 158 </AlertDialogTitle> 159 <AlertDialogDescription> 160 This action cannot be undone. This will permanently remove the entry 161 from the database. 162 </AlertDialogDescription> 163 </AlertDialogHeader> 164 {deleteAction?.confirmationValue && ( 165 <form id="form-alert-dialog" className="space-y-1.5"> 166 <p className="text-muted-foreground text-sm"> 167 Type{" "} 168 <Button 169 variant="secondary" 170 size="sm" 171 type="button" 172 className="font-normal [&_svg]:size-3" 173 onClick={() => 174 copy(deleteAction.confirmationValue || "", { 175 withToast: false, 176 }) 177 } 178 > 179 {deleteAction.confirmationValue} 180 {isCopied ? <Check /> : <Copy />} 181 </Button>{" "} 182 to confirm 183 </p> 184 <Input value={value} onChange={(e) => setValue(e.target.value)} /> 185 </form> 186 )} 187 <AlertDialogFooter> 188 <AlertDialogCancel onClick={(e) => e.stopPropagation()}> 189 Cancel 190 </AlertDialogCancel> 191 <AlertDialogAction 192 className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" 193 disabled={ 194 (deleteAction?.confirmationValue && 195 value !== deleteAction?.confirmationValue) || 196 isPending 197 } 198 form="form-alert-dialog" 199 type="submit" 200 onClick={(e) => { 201 e.preventDefault(); 202 handleDelete(); 203 }} 204 > 205 {isPending ? "Deleting..." : "Delete"} 206 </AlertDialogAction> 207 </AlertDialogFooter> 208 </AlertDialogContent> 209 </AlertDialog> 210 ); 211}