Openstatus
www.openstatus.dev
1"use client";
2
3import * as Portal from "@radix-ui/react-portal";
4import type { Table } from "@tanstack/react-table";
5import { useEffect, useState, useTransition } from "react";
6
7import { Kbd } from "@/components/kbd";
8import { LoadingAnimation } from "@/components/loading-animation";
9import { toast, toastAction } from "@/lib/toast";
10import { cn } from "@/lib/utils";
11import { api } from "@/trpc/client";
12import type { Monitor, MonitorTag } from "@openstatus/db/src/schema";
13import {
14 AlertDialog,
15 AlertDialogAction,
16 AlertDialogCancel,
17 AlertDialogContent,
18 AlertDialogDescription,
19 AlertDialogFooter,
20 AlertDialogHeader,
21 AlertDialogTitle,
22 AlertDialogTrigger,
23 Button,
24 Command,
25 CommandEmpty,
26 CommandGroup,
27 CommandInput,
28 CommandItem,
29 CommandList,
30 Popover,
31 PopoverContent,
32 PopoverTrigger,
33 Select,
34 SelectContent,
35 SelectGroup,
36 SelectItem,
37 SelectLabel,
38 SelectTrigger,
39 SelectValue,
40 Tooltip,
41 TooltipContent,
42 TooltipProvider,
43 TooltipTrigger,
44} from "@openstatus/ui";
45import { Check, X } from "lucide-react";
46import { useRouter } from "next/navigation";
47
48interface DataTableFloatingActions<TData> {
49 table: Table<TData>;
50 actions?: [];
51 tags?: MonitorTag[];
52}
53
54export function DataTableFloatingActions<TData>({
55 table,
56 tags,
57}: DataTableFloatingActions<TData>) {
58 const router = useRouter();
59 const [alertOpen, setAlertOpen] = useState(false);
60 const [isPending, startTransition] = useTransition();
61 const [method, setMethod] = useState<
62 "delete" | "active" | "public" | "tag" | null
63 >(null);
64 const rows = table.getFilteredSelectedRowModel().rows;
65
66 // clear selection on escape key
67 useEffect(() => {
68 function handleKeyDown(event: KeyboardEvent) {
69 if (event.key === "Escape") {
70 table.toggleAllRowsSelected(false);
71 }
72 }
73
74 window.addEventListener("keydown", handleKeyDown);
75 return () => window.removeEventListener("keydown", handleKeyDown);
76 }, [table]);
77
78 if (table.getFilteredSelectedRowModel().rows.length === 0) {
79 return null;
80 }
81
82 function handleUpdates(props: Partial<Pick<Monitor, "active" | "public">>) {
83 startTransition(async () => {
84 toast.promise(
85 async () => {
86 await api.monitor.updateMonitors.mutate({
87 ids: rows.map((row) => row.getValue("id")),
88 ...props,
89 });
90 router.refresh();
91 },
92 {
93 loading: "Updating monitor(s)...",
94 success: "Monitor(s) updated!",
95 error: "Something went wrong!",
96 finally: () => {},
97 },
98 );
99 });
100 }
101
102 function handleTagUpdates(props: {
103 tagId: number;
104 action: "add" | "remove";
105 }) {
106 startTransition(async () => {
107 toast.promise(
108 async () => {
109 await api.monitor.updateMonitorsTag.mutate({
110 ids: rows.map((row) => row.getValue("id")),
111 ...props,
112 });
113 router.refresh();
114 },
115 {
116 loading:
117 props.action === "add"
118 ? "Adding tag to monitor(s)..."
119 : "Removing tag from monitor(s)...",
120 success:
121 props.action === "add"
122 ? "Tag added to monitor(s)!"
123 : "Tag removed from monitor(s)",
124 error: "Something went wrong!",
125 finally: () => {},
126 },
127 );
128 });
129 }
130
131 function handleDeletes() {
132 startTransition(async () => {
133 try {
134 await api.monitor.deleteMonitors.mutate({
135 ids: rows.map((row) => row.getValue("id")),
136 });
137 setAlertOpen(false);
138 table.toggleAllRowsSelected(false);
139 router.refresh();
140 } catch (error) {
141 console.error(error);
142 toastAction("error");
143 }
144 });
145 }
146
147 // TODO: can we make it smarter! Its ugly as hell
148
149 const statusValue = rows.every((row) => row.getValue("active") === true)
150 ? "true"
151 : rows.every((row) => row.getValue("active") === false)
152 ? "false"
153 : undefined;
154
155 const visibilityValue = rows.every((row) => row.getValue("public") === true)
156 ? "true"
157 : rows.every((row) => row.getValue("public") === false)
158 ? "false"
159 : undefined;
160
161 const everyTagValue =
162 tags?.filter((tag) => {
163 return rows.every((row) => {
164 const _tags = row.getValue("tags");
165 if (Array.isArray(_tags)) {
166 return _tags.map(({ id }) => id)?.includes(tag.id);
167 }
168 return false;
169 });
170 }) || [];
171
172 const someTagValue =
173 tags?.filter((tag) => {
174 return rows.some((row) => {
175 const _tags = row.getValue("tags");
176 if (Array.isArray(_tags)) {
177 return _tags.map(({ id }) => id)?.includes(tag.id);
178 }
179 return false;
180 });
181 }) || [];
182
183 return (
184 <Portal.Root>
185 <div className="fixed inset-x-0 bottom-4 z-50 mx-auto w-fit px-4">
186 <div className="flex flex-wrap items-center gap-2 rounded-md border bg-background px-4 py-2 shadow-sm">
187 <TooltipProvider>
188 <Tooltip>
189 <TooltipTrigger asChild>
190 <Button
191 variant="ghost"
192 onClick={() => table.toggleAllRowsSelected(false)}
193 className="border border-dashed"
194 >
195 <span className="whitespace-nowrap">
196 {rows.length} selected
197 </span>
198 <X className="ml-1.5 size-4 shrink-0" />
199 </Button>
200 </TooltipTrigger>
201 <TooltipContent className="flex items-center">
202 <p className="mr-2">Clear selection</p>
203 <Kbd abbrTitle="Escape" variant="outline">
204 Esc
205 </Kbd>
206 </TooltipContent>
207 </Tooltip>
208 </TooltipProvider>
209 <AlertDialog
210 open={alertOpen}
211 onOpenChange={(value) => setAlertOpen(value)}
212 >
213 <AlertDialogTrigger asChild>
214 <Button
215 variant="outline"
216 className="border-destructive text-destructive hover:bg-destructive hover:text-background"
217 >
218 Delete
219 </Button>
220 </AlertDialogTrigger>
221 <AlertDialogContent>
222 <AlertDialogHeader>
223 <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
224 <AlertDialogDescription>
225 This action cannot be undone. This will permanently delete the
226 selected monitor(s).
227 </AlertDialogDescription>
228 </AlertDialogHeader>
229 <AlertDialogFooter>
230 <AlertDialogCancel>Cancel</AlertDialogCancel>
231 <AlertDialogAction
232 onClick={() => {
233 setMethod("delete");
234 handleDeletes();
235 }}
236 className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
237 >
238 {isPending && method === "delete" ? (
239 <LoadingAnimation />
240 ) : (
241 "Delete"
242 )}
243 </AlertDialogAction>
244 </AlertDialogFooter>
245 </AlertDialogContent>
246 </AlertDialog>
247 <Select
248 disabled={isPending && method === "active"}
249 value={statusValue}
250 onValueChange={(value) => {
251 setMethod("active");
252 handleUpdates({ active: value === "true" });
253 }}
254 >
255 <SelectTrigger className="h-9 max-w-fit">
256 <SelectValue placeholder="Status" />
257 </SelectTrigger>
258 <SelectContent>
259 <SelectGroup>
260 <SelectLabel>Status</SelectLabel>
261 <SelectItem value="true">Active</SelectItem>
262 <SelectItem value="false">Inactive</SelectItem>
263 </SelectGroup>
264 </SelectContent>
265 </Select>
266 <Popover>
267 <PopoverTrigger disabled={isPending && method === "tag"} asChild>
268 <Button variant="outline" className="flex items-center gap-2">
269 <span>Tags</span>
270 {everyTagValue.length ? (
271 <div className="relative flex overflow-hidden">
272 {everyTagValue.map((tag) => (
273 <div
274 key={tag.id}
275 style={{ backgroundColor: tag.color }}
276 className="h-2.5 w-2.5 rounded-full ring-2 ring-background"
277 />
278 ))}
279 </div>
280 ) : null}
281 </Button>
282 </PopoverTrigger>
283 <PopoverContent className="w-[200px] p-0" align="start">
284 <Command>
285 <CommandInput placeholder={"Tags"} />
286 <CommandList>
287 <CommandEmpty>No results found.</CommandEmpty>
288 <CommandGroup>
289 {tags?.map((tag) => {
290 const isSelected = everyTagValue
291 .map((tag) => tag.id)
292 ?.includes(tag.id);
293 const isIndeterminated = !isSelected
294 ? someTagValue.map((tag) => tag.id)?.includes(tag.id)
295 : false;
296 return (
297 <CommandItem
298 key={String(tag.name)}
299 onSelect={() => {
300 setMethod("tag");
301 handleTagUpdates({
302 tagId: tag.id,
303 action:
304 !isSelected || isIndeterminated
305 ? "add"
306 : "remove",
307 });
308 }}
309 >
310 <div
311 className={cn(
312 "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
313 {
314 "bg-primary text-primary-foreground":
315 isSelected,
316 "text-muted-foreground": isIndeterminated,
317 "opacity-50 [&_svg]:invisible":
318 !isSelected && !isIndeterminated,
319 },
320 )}
321 >
322 <Check className={cn("h-4 w-4")} />
323 </div>
324 <div className="flex w-full items-center justify-between">
325 <span>{tag.name}</span>
326 <div
327 key={tag.id}
328 style={{ backgroundColor: tag.color }}
329 className="h-2.5 w-2.5 rounded-full"
330 />
331 </div>
332 </CommandItem>
333 );
334 })}
335 </CommandGroup>
336 </CommandList>
337 </Command>
338 </PopoverContent>
339 </Popover>
340 <Select
341 disabled={isPending && method === "public"}
342 value={visibilityValue}
343 onValueChange={(value) => {
344 setMethod("public");
345 handleUpdates({ public: value === "true" });
346 }}
347 >
348 <SelectTrigger className="h-9 max-w-fit">
349 <SelectValue placeholder="Visibility" />
350 </SelectTrigger>
351 <SelectContent>
352 <SelectGroup>
353 <SelectLabel>Visibility</SelectLabel>
354 <SelectItem value="true">Public</SelectItem>
355 <SelectItem value="false">Private</SelectItem>
356 </SelectGroup>
357 </SelectContent>
358 </Select>
359 </div>
360 </div>
361 </Portal.Root>
362 );
363}