Openstatus
www.openstatus.dev
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}