Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
13 kB ยท 402 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403"use client";
import { zodResolver } from "@hookform/resolvers/zod";import { Add, Close } from "@openstatus/icons";import { detectWebhookFlavor } from "@openstatus/subscriptions/client";import { Badge } from "@openstatus/ui/components/ui/badge";import { Button } from "@openstatus/ui/components/ui/button";import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from "@openstatus/ui/components/ui/form";import { Input } from "@openstatus/ui/components/ui/input";import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs";import { cn } from "@openstatus/ui/lib/utils";import { isTRPCClientError } from "@trpc/client";import React, { useTransition } from "react";import { useFieldArray, useForm } from "react-hook-form";import { toast } from "sonner";import { z } from "zod";
import { EmptyStateContainer, EmptyStateTitle,} from "@/components/content/empty-state";import { FormCardContent, FormCardSeparator,} from "@/components/forms/form-card";import { useFormSheetDirty } from "@/components/forms/form-sheet";import { CheckboxTree, type CheckboxTreeItem,} from "@/components/ui/checkbox-tree";
const headerSchema = z.object({ key: z.string().min(1, "Key is required"), value: z.string(),});
// Form schema: a single flat shape with optional fields. The submit handler// narrows to email-only or webhook-only payload based on `channelType`.const formSchema = z .object({ channelType: z.enum(["email", "webhook"]), name: z.string().max(255), email: z.string(), webhookUrl: z.string(), headers: z.array(headerSchema), componentIds: z.array(z.number()), }) .superRefine((data, ctx) => { if (data.channelType === "email") { const result = z.email().safeParse(data.email); if (!result.success) { ctx.addIssue({ code: "custom", path: ["email"], message: "Please enter a valid email", }); } } else { const result = z.url().safeParse(data.webhookUrl); if (!result.success) { ctx.addIssue({ code: "custom", path: ["webhookUrl"], message: "Please enter a valid URL", }); } } });
export type SubscriberFormValues = z.infer<typeof formSchema>;
export type SubmitPayload = | { channelType: "email"; name: string; email: string; componentIds: number[]; } | { channelType: "webhook"; name: string; webhookUrl: string; headers: { key: string; value: string }[]; componentIds: number[]; };
function toPayload(values: SubscriberFormValues): SubmitPayload { if (values.channelType === "email") { return { channelType: "email", name: values.name, email: values.email, componentIds: values.componentIds, }; } return { channelType: "webhook", name: values.name, webhookUrl: values.webhookUrl, headers: values.headers, componentIds: values.componentIds, };}
const emptyDefaults: SubscriberFormValues = { channelType: "email", name: "", email: "", webhookUrl: "", headers: [], componentIds: [],};
export function FormSubscriber({ defaultValues, onSubmit, className, items, editMode = false, ...props}: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: Partial<SubscriberFormValues>; items: CheckboxTreeItem[]; onSubmit: (values: SubmitPayload) => Promise<void>; /** When true, channel type is locked (editing an existing subscriber). */ editMode?: boolean;}) { const form = useForm<SubscriberFormValues>({ resolver: zodResolver(formSchema), defaultValues: { ...emptyDefaults, ...defaultValues }, });
const channelType = form.watch("channelType"); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty();
const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]);
const { fields, append, remove } = useFieldArray({ control: form.control, name: "headers", });
function submitAction(values: SubscriberFormValues) { if (isPending) return;
startTransition(async () => { try { const promise = onSubmit(toPayload(values)); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); }
return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent> <FormField control={form.control} name="channelType" render={({ field }) => ( <FormItem> <FormLabel>Channel</FormLabel> <FormControl> <Tabs value={field.value} onValueChange={(v) => { if (editMode) return; if (v === "email" || v === "webhook") { field.onChange(v); } }} > <TabsList> <TabsTrigger value="email" disabled={editMode}> Email </TabsTrigger> <TabsTrigger value="webhook" disabled={editMode}> Webhook </TabsTrigger> </TabsList> </Tabs> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Display label (optional)</FormLabel> <FormControl> <Input placeholder={ channelType === "webhook" ? "Supabase #incidents" : "Alice (Partner CTO)" } {...field} /> </FormControl> <FormDescription> Shown in place of the raw destination in the dashboard. </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> {channelType === "email" ? ( <FormCardContent> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" placeholder="partner@example.com" autoComplete="off" readOnly={editMode} {...field} /> </FormControl> {editMode ? ( <FormDescription> Email address is immutable. Delete and re-add to change it. </FormDescription> ) : ( <FormDescription> By adding this email, you confirm this contact has consented to receive status updates. We'll not be sending a confirmation email. </FormDescription> )} <FormMessage /> </FormItem> )} /> </FormCardContent> ) : ( <> <FormCardContent> <FormField control={form.control} name="webhookUrl" render={({ field }) => ( <FormItem> <FormLabel>Webhook URL</FormLabel> <FormControl> <Input placeholder="https://hooks.slack.com/services/โฆ" autoComplete="off" {...field} /> </FormControl> <FormDescription> <span className="mr-0.5"> Slack and Discord URLs receive channel-native messages; any other URL receives a generic JSON payload. </span> {/^https?:\/\//.test(field.value) ? ( <Badge variant="outline" className="capitalize"> {detectWebhookFlavor(field.value)} </Badge> ) : null} </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormItem> <FormLabel>Request Headers</FormLabel> <FormDescription> Custom headers to include in every webhook request. </FormDescription> {fields.map((f, idx) => ( <div key={f.id} className="grid gap-2 sm:grid-cols-5"> <FormField control={form.control} name={`headers.${idx}.key`} render={({ field }) => ( <FormItem className="col-span-2"> <FormControl> <Input placeholder="Key" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`headers.${idx}.value`} render={({ field }) => ( <FormItem className="col-span-2"> <FormControl> <Input placeholder="Value" {...field} /> </FormControl> </FormItem> )} /> <Button size="icon" variant="ghost" type="button" aria-label="Remove header" onClick={() => remove(idx)} > <Close /> </Button> </div> ))} <div> <Button size="sm" variant="outline" type="button" onClick={() => append({ key: "", value: "" })} > <Add /> Add Header </Button> </div> <FormMessage /> </FormItem> </FormCardContent> </> )} <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="componentIds" render={({ field }) => ( <FormItem> <FormLabel>Page Components</FormLabel> <FormDescription> Leave empty to notify for the entire page. Select components to only notify on matching reports. </FormDescription> {items.length ? ( <FormControl> <CheckboxTree items={items} value={field.value ?? []} onValueChange={field.onChange} /> </FormControl> ) : ( <EmptyStateContainer> <EmptyStateTitle>No page components found</EmptyStateTitle> </EmptyStateContainer> )} <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> );}