Openstatus www.openstatus.dev
at main 509 lines 19 kB view raw
1"use client"; 2 3import { 4 EmptyStateContainer, 5 EmptyStateTitle, 6} from "@/components/content/empty-state"; 7import { ProcessMessage } from "@/components/content/process-message"; 8import { 9 FormCardContent, 10 FormCardSeparator, 11} from "@/components/forms/form-card"; 12import { useFormSheetDirty } from "@/components/forms/form-sheet"; 13import { Button } from "@/components/ui/button"; 14import { Calendar } from "@/components/ui/calendar"; 15import { Checkbox } from "@/components/ui/checkbox"; 16import { 17 Form, 18 FormControl, 19 FormDescription, 20 FormField, 21 FormItem, 22 FormLabel, 23 FormMessage, 24} from "@/components/ui/form"; 25import { Input } from "@/components/ui/input"; 26import { Label } from "@/components/ui/label"; 27import { 28 Popover, 29 PopoverContent, 30 PopoverTrigger, 31} from "@/components/ui/popover"; 32import { TabsContent } from "@/components/ui/tabs"; 33import { TabsList, TabsTrigger } from "@/components/ui/tabs"; 34import { Tabs } from "@/components/ui/tabs"; 35import { Textarea } from "@/components/ui/textarea"; 36import { useIsMobile } from "@/hooks/use-mobile"; 37import { useTRPC } from "@/lib/trpc/client"; 38import { cn } from "@/lib/utils"; 39import { zodResolver } from "@hookform/resolvers/zod"; 40import { useQuery } from "@tanstack/react-query"; 41import { isTRPCClientError } from "@trpc/client"; 42import { addDays, format } from "date-fns"; 43import { CalendarIcon, ClockIcon } from "lucide-react"; 44import React, { useTransition } from "react"; 45import { useForm } from "react-hook-form"; 46import { toast } from "sonner"; 47import { z } from "zod"; 48 49const schema = z 50 .object({ 51 title: z.string().min(1, "Title is required"), 52 message: z.string(), 53 startDate: z.date(), 54 endDate: z.date(), 55 pageComponents: z.array(z.number()), 56 notifySubscribers: z.boolean().optional(), 57 }) 58 .refine((data) => data.endDate > data.startDate, { 59 error: "End date cannot be earlier than start date.", 60 path: ["endDate"], 61 }); 62 63export type FormValues = z.infer<typeof schema>; 64 65export function FormMaintenance({ 66 defaultValues, 67 onSubmit, 68 className, 69 pageComponents, 70 ...props 71}: Omit<React.ComponentProps<"form">, "onSubmit"> & { 72 defaultValues?: FormValues; 73 pageComponents: { id: number; name: string }[]; 74 onSubmit: (values: FormValues) => Promise<void>; 75}) { 76 const trpc = useTRPC(); 77 const { data: workspace } = useQuery( 78 trpc.workspace.getWorkspace.queryOptions(), 79 ); 80 const mobile = useIsMobile(); 81 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 82 const form = useForm<FormValues>({ 83 resolver: zodResolver(schema), 84 defaultValues: defaultValues ?? { 85 title: "", 86 message: "", 87 startDate: new Date(), 88 endDate: addDays(new Date(), 1), 89 pageComponents: [], 90 notifySubscribers: true, 91 }, 92 }); 93 const watchEndDate = form.watch("endDate"); 94 const watchMessage = form.watch("message"); 95 const [isPending, startTransition] = useTransition(); 96 const { setIsDirty } = useFormSheetDirty(); 97 98 const formIsDirty = form.formState.isDirty; 99 React.useEffect(() => { 100 setIsDirty(formIsDirty); 101 }, [formIsDirty, setIsDirty]); 102 103 function submitAction(values: FormValues) { 104 if (isPending) return; 105 106 startTransition(async () => { 107 try { 108 const promise = onSubmit(values); 109 toast.promise(promise, { 110 loading: "Saving...", 111 success: () => "Saved", 112 error: (error) => { 113 if (isTRPCClientError(error)) { 114 return error.message; 115 } 116 return "Failed to save"; 117 }, 118 }); 119 await promise; 120 } catch (error) { 121 console.error(error); 122 } 123 }); 124 } 125 126 return ( 127 <Form {...form}> 128 <form 129 className={cn("grid gap-4", className)} 130 onSubmit={form.handleSubmit(submitAction)} 131 {...props} 132 > 133 <FormCardContent> 134 <FormField 135 control={form.control} 136 name="title" 137 render={({ field }) => ( 138 <FormItem> 139 <FormLabel>Title</FormLabel> 140 <FormControl> 141 <Input placeholder="DB migration..." {...field} /> 142 </FormControl> 143 <FormMessage /> 144 </FormItem> 145 )} 146 /> 147 </FormCardContent> 148 <FormCardSeparator /> 149 <FormCardContent> 150 {/* TODO: */} 151 <FormField 152 control={form.control} 153 name="startDate" 154 render={({ field }) => ( 155 <FormItem className="flex flex-col"> 156 <FormLabel>Start Date</FormLabel> 157 <Popover modal> 158 <FormControl> 159 <PopoverTrigger asChild> 160 <Button 161 type="button" 162 variant="outline" 163 size="sm" 164 className={cn( 165 "w-[240px] pl-3 text-left font-normal", 166 !field.value && "text-muted-foreground", 167 )} 168 > 169 {field.value ? ( 170 format(field.value, "PPP 'at' h:mm a") 171 ) : ( 172 <span>Pick a date</span> 173 )} 174 <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> 175 </Button> 176 </PopoverTrigger> 177 </FormControl> 178 <PopoverContent 179 className="pointer-events-auto w-auto p-0" 180 align="start" 181 side={mobile ? "bottom" : "left"} 182 > 183 <Calendar 184 mode="single" 185 selected={field.value} 186 onSelect={(selectedDate) => { 187 if (!selectedDate) return; 188 189 const newDate = new Date(selectedDate); 190 newDate.setHours( 191 field.value.getHours(), 192 field.value.getMinutes(), 193 field.value.getSeconds(), 194 field.value.getMilliseconds(), 195 ); 196 field.onChange(newDate); 197 198 // NOTE: if end date is before start date, set it to the same day as the start date 199 if (watchEndDate && newDate > watchEndDate) { 200 form.setValue("endDate", newDate); 201 } 202 }} 203 initialFocus 204 /> 205 <div className="border-t p-3"> 206 <div className="flex items-center gap-3"> 207 <Label htmlFor="time-start" className="text-xs"> 208 Enter time 209 </Label> 210 <div className="relative grow"> 211 <Input 212 id="time-start" 213 type="time" 214 step="1" 215 value={ 216 field.value 217 ? field.value.toTimeString().slice(0, 8) 218 : new Date().toTimeString().slice(0, 8) 219 } 220 className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" 221 onChange={(e) => { 222 try { 223 const timeValue = e.target.value; 224 if (!timeValue || !field.value) return; 225 226 const [hours, minutes, seconds] = timeValue 227 .split(":") 228 .map(Number); 229 230 const newDate = new Date(field.value); 231 newDate.setHours( 232 hours, 233 minutes, 234 seconds || 0, 235 0, 236 ); 237 238 field.onChange(newDate); 239 } catch (error) { 240 console.error(error); 241 } 242 }} 243 /> 244 <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> 245 <ClockIcon size={16} aria-hidden="true" /> 246 </div> 247 </div> 248 </div> 249 </div> 250 </PopoverContent> 251 </Popover> 252 <FormDescription> 253 When the maintenance starts. Shown in your timezone ( 254 <code className="font-commit-mono text-foreground/70"> 255 {timezone} 256 </code> 257 ) and saved as Unix time ( 258 <code className="font-commit-mono text-foreground/70"> 259 UTC 260 </code> 261 ). 262 </FormDescription> 263 <FormMessage /> 264 </FormItem> 265 )} 266 /> 267 </FormCardContent> 268 <FormCardSeparator /> 269 <FormCardContent> 270 <FormField 271 control={form.control} 272 name="endDate" 273 render={({ field }) => ( 274 <FormItem className="flex flex-col"> 275 <FormLabel>End Date</FormLabel> 276 <Popover modal> 277 <FormControl> 278 <PopoverTrigger asChild> 279 <Button 280 type="button" 281 variant="outline" 282 size="sm" 283 className={cn( 284 "w-[240px] pl-3 text-left font-normal", 285 !field.value && "text-muted-foreground", 286 )} 287 > 288 {field.value ? ( 289 format(field.value, "PPP 'at' h:mm a") 290 ) : ( 291 <span>Pick a date</span> 292 )} 293 <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> 294 </Button> 295 </PopoverTrigger> 296 </FormControl> 297 <PopoverContent 298 className="pointer-events-auto w-auto p-0" 299 align="start" 300 side={mobile ? "bottom" : "left"} 301 > 302 <Calendar 303 mode="single" 304 selected={field.value} 305 onSelect={(selectedDate) => { 306 if (!selectedDate) return; 307 308 const newDate = new Date(selectedDate); 309 newDate.setHours( 310 field.value.getHours(), 311 field.value.getMinutes(), 312 field.value.getSeconds(), 313 field.value.getMilliseconds(), 314 ); 315 field.onChange(newDate); 316 }} 317 initialFocus 318 /> 319 <div className="border-t p-3"> 320 <div className="flex items-center gap-3"> 321 <Label htmlFor="time-end" className="text-xs"> 322 Enter time 323 </Label> 324 <div className="relative grow"> 325 <Input 326 id="time-end" 327 type="time" 328 step="1" 329 value={ 330 field.value 331 ? field.value.toTimeString().slice(0, 8) 332 : new Date().toTimeString().slice(0, 8) 333 } 334 className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" 335 onChange={(e) => { 336 try { 337 const timeValue = e.target.value; 338 if (!timeValue || !field.value) return; 339 340 const [hours, minutes, seconds] = timeValue 341 .split(":") 342 .map(Number); 343 344 const newDate = new Date(field.value); 345 newDate.setHours( 346 hours, 347 minutes, 348 seconds || 0, 349 0, 350 ); 351 352 field.onChange(newDate); 353 } catch (error) { 354 console.error(error); 355 } 356 }} 357 /> 358 <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> 359 <ClockIcon size={16} aria-hidden="true" /> 360 </div> 361 </div> 362 </div> 363 </div> 364 </PopoverContent> 365 </Popover> 366 <FormDescription> 367 When the maintenance ends. Shown in your timezone ( 368 <code className="font-commit-mono text-foreground/70"> 369 {timezone} 370 </code> 371 ) and saved as Unix time ( 372 <code className="font-commit-mono text-foreground/70"> 373 UTC 374 </code> 375 ). 376 </FormDescription> 377 <FormMessage /> 378 </FormItem> 379 )} 380 /> 381 </FormCardContent> 382 <FormCardSeparator /> 383 <FormCardContent> 384 <Tabs defaultValue="tab-1"> 385 <TabsList> 386 <TabsTrigger value="tab-1">Writing</TabsTrigger> 387 <TabsTrigger value="tab-2">Preview</TabsTrigger> 388 </TabsList> 389 <TabsContent value="tab-1"> 390 <FormField 391 control={form.control} 392 name="message" 393 render={({ field }) => ( 394 <FormItem> 395 <FormLabel>Message</FormLabel> 396 <FormControl> 397 <Textarea rows={6} {...field} /> 398 </FormControl> 399 <FormMessage /> 400 <FormDescription>Markdown support</FormDescription> 401 </FormItem> 402 )} 403 /> 404 </TabsContent> 405 <TabsContent value="tab-2"> 406 <div className="grid gap-2"> 407 <Label>Preview</Label> 408 <div className="prose dark:prose-invert prose-sm rounded-md border px-3 py-2 text-foreground text-sm"> 409 <ProcessMessage value={watchMessage} /> 410 </div> 411 </div> 412 </TabsContent> 413 </Tabs> 414 </FormCardContent> 415 <FormCardSeparator /> 416 <FormCardContent> 417 <FormField 418 control={form.control} 419 name="pageComponents" 420 render={({ field }) => ( 421 <FormItem> 422 <FormLabel>Page Components</FormLabel> 423 <FormDescription> 424 Connected page components will be affected for the period of 425 time. 426 </FormDescription> 427 {pageComponents.length ? ( 428 <div className="grid gap-3"> 429 <div className="flex items-center gap-2"> 430 <FormControl> 431 <Checkbox 432 id="all" 433 checked={ 434 field.value?.length === pageComponents.length 435 } 436 onCheckedChange={(checked) => { 437 field.onChange( 438 checked ? pageComponents.map((c) => c.id) : [], 439 ); 440 }} 441 /> 442 </FormControl> 443 <Label htmlFor="all">Select all</Label> 444 </div> 445 {pageComponents.map((item) => ( 446 <div key={item.id} className="flex items-center gap-2"> 447 <FormControl> 448 <Checkbox 449 id={String(item.id)} 450 checked={field.value?.includes(item.id)} 451 onCheckedChange={(checked) => { 452 const newValue = checked 453 ? [...(field.value || []), item.id] 454 : field.value?.filter((id) => id !== item.id); 455 field.onChange(newValue); 456 }} 457 /> 458 </FormControl> 459 <Label htmlFor={String(item.id)}>{item.name}</Label> 460 </div> 461 ))} 462 </div> 463 ) : ( 464 <EmptyStateContainer> 465 <EmptyStateTitle>No page components found</EmptyStateTitle> 466 </EmptyStateContainer> 467 )} 468 <FormMessage /> 469 </FormItem> 470 )} 471 /> 472 </FormCardContent> 473 {!defaultValues && workspace?.limits["status-subscribers"] ? ( 474 <> 475 <FormCardSeparator /> 476 <FormCardContent> 477 <FormField 478 control={form.control} 479 name="notifySubscribers" 480 render={({ field }) => ( 481 <FormItem> 482 <FormLabel>Notify Subscribers</FormLabel> 483 <FormControl> 484 <div className="flex items-center gap-2"> 485 <Checkbox 486 id="notifySubscribers" 487 checked={field.value} 488 onCheckedChange={field.onChange} 489 /> 490 <Label htmlFor="notifySubscribers"> 491 Send email notification to subscribers 492 </Label> 493 </div> 494 </FormControl> 495 <FormMessage /> 496 <FormDescription> 497 Subscribers will receive an email when creating a 498 maintenance. 499 </FormDescription> 500 </FormItem> 501 )} 502 /> 503 </FormCardContent> 504 </> 505 ) : null} 506 </form> 507 </Form> 508 ); 509}