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