Openstatus www.openstatus.dev

feat: notification-form (#915)

* wip: notification form

* wip: notification form

* fix: onboarding flow

* fix: chart tooltip and testimonial

* fix: comments

authored by

Maximilian Kaske and committed by
GitHub
f0ed1561 bc6a0ca6

+333 -446
+7 -2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx
··· 1 - import { NotificationForm } from "@/components/forms/notification-form"; 1 + import { NotificationForm } from "@/components/forms/notification/form"; 2 2 import { api } from "@/trpc/server"; 3 3 4 4 export default async function EditPage({ ··· 7 7 params: { workspaceSlug: string; id: string }; 8 8 }) { 9 9 const workspace = await api.workspace.getWorkspace.query(); 10 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 10 11 11 12 const notification = await api.notification.getNotificationById.query({ 12 13 id: Number(params.id), ··· 14 15 15 16 return ( 16 17 <NotificationForm 17 - defaultValues={notification} 18 + defaultValues={{ 19 + ...notification, 20 + monitors: notification.monitor.map(({ monitor }) => monitor.id), 21 + }} 22 + monitors={monitors} 18 23 workspacePlan={workspace.plan} 19 24 provider={notification.provider} 20 25 />
+3 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx
··· 1 1 import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 - import { NotificationForm } from "@/components/forms/notification-form"; 2 + import { NotificationForm } from "@/components/forms/notification/form"; 3 3 import { api } from "@/trpc/server"; 4 4 import { notificationProviderSchema } from "@openstatus/db/src/schema"; 5 5 import { getLimit } from "@openstatus/plans"; ··· 17 17 if (!validation.success) notFound(); 18 18 19 19 const workspace = await api.workspace.getWorkspace.query(); 20 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 20 21 21 22 const provider = validation.data; 22 23 ··· 36 37 workspacePlan={workspace.plan} 37 38 nextUrl="../" 38 39 provider={provider} 40 + monitors={monitors} 39 41 /> 40 42 ); 41 43 }
+3 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx
··· 1 1 import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 - import { NotificationForm } from "@/components/forms/notification-form"; 2 + import { NotificationForm } from "@/components/forms/notification/form"; 3 3 import { api } from "@/trpc/server"; 4 4 import { PagerDutySchema } from "@openstatus/notification-pagerduty"; 5 5 import { getLimit } from "@openstatus/plans"; ··· 17 17 searchParams: { [key: string]: string | string[] | undefined }; 18 18 }) { 19 19 const workspace = await api.workspace.getWorkspace.query(); 20 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 20 21 const params = searchParamsSchema.parse(searchParams); 21 22 22 23 if (!params.config) { ··· 39 40 nextUrl="../" 40 41 provider="pagerduty" 41 42 callbackData={params.config} 43 + monitors={monitors} 42 44 /> 43 45 </> 44 46 );
+5 -4
apps/web/src/app/app/[workspaceSlug]/onboarding/page.tsx
··· 4 4 import { Button } from "@openstatus/ui"; 5 5 6 6 import { Header } from "@/components/dashboard/header"; 7 - import { MonitorForm } from "@/components/forms/monitor-form"; 8 7 import { StatusPageForm } from "@/components/forms/status-page/form"; 9 8 import { api } from "@/trpc/server"; 10 9 import { Description } from "./_components/description"; 11 - 12 - // FIXME: uses legact MonitorForm and StatusPageForm 10 + import { MonitorForm } from "@/components/forms/monitor/form"; 13 11 14 12 export default async function Onboarding({ 15 13 params, ··· 37 35 /> 38 36 <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 39 37 <div className="md:col-span-2"> 40 - <MonitorForm notifications={allNotifications} /> 38 + <MonitorForm 39 + notifications={allNotifications} 40 + defaultSection="request" 41 + /> 41 42 </div> 42 43 <div className="hidden h-full md:col-span-1 md:block"> 43 44 <Description step="monitor" />
+15
apps/web/src/app/app/onboarding/page.tsx
··· 1 + import { auth } from "@/lib/auth"; 2 + import { api } from "@/trpc/server"; 3 + import { redirect } from "next/navigation"; 4 + 5 + export default async function OnboardingPage() { 6 + const session = await auth(); 7 + 8 + if (!session) redirect("/app/login"); 9 + 10 + const workspace = await api.workspace.getWorkspace.query(); 11 + 12 + if (!workspace) redirect("/app/login"); 13 + 14 + return redirect(`/app/${workspace.slug}/onboarding`); 15 + }
+13 -14
apps/web/src/app/play/checker/_components/testimonial.tsx
··· 1 - import { Shell } from "@/components/dashboard/shell"; 1 + import Image from "next/image"; 2 2 3 3 export const Testimonial = () => { 4 4 return ( 5 5 <div className="mx-auto max-w-2xl lg:max-w-4xl"> 6 - <figure className="mt-10"> 7 - <blockquote className="text-center font-semibold text-2xl leading-8 sm:text-2xl sm:leading-9"> 8 - <p>“Just don't give up on your users”</p> 6 + <figure className="grid gap-4"> 7 + <blockquote className="text-center font-semibold text-xl leading-8 after:text-muted-foreground before:text-muted-foreground sm:text-2xl sm:leading-9 after:content-['”'] before:content-['“']"> 8 + Just don't give up on your users 9 9 </blockquote> 10 - <figcaption className="mt-10"> 11 - <div className="mt-4 flex items-center justify-center space-x-3 text-base"> 12 - <div> 13 - <div className="font-semibold">Glauber Costa</div> 14 - 15 - <div className="text-muted-foreground">CEO of Turso</div> 16 - </div> 17 - <img 18 - className="h-10 w-10 rounded-full" 10 + <figcaption className="flex items-center justify-center space-x-3 text-base"> 11 + <div className="text-sm"> 12 + <div className="font-medium">Glauber Costa</div> 13 + <div className="text-muted-foreground">CEO of Turso</div> 14 + </div> 15 + <div className="relative h-10 w-10 overflow-hidden rounded-full border"> 16 + <Image 17 + fill={true} 19 18 src="/assets/checker/glauber.png" 20 - alt="" 19 + alt="Glauber Costa" 21 20 /> 22 21 </div> 23 22 </figcaption>
+2 -8
apps/web/src/components/forms/monitor-form.tsx
··· 1 + // REMINDER: legacy form - please use /forms/monitor/form.tsx 2 + 1 3 "use client"; 2 4 3 5 import { zodResolver } from "@hookform/resolvers/zod"; ··· 17 19 monitorMethods, 18 20 monitorMethodsSchema, 19 21 monitorPeriodicitySchema, 20 - workspacePlans, 21 22 } from "@openstatus/db/src/schema"; 22 23 import { getLimit } from "@openstatus/plans"; 23 24 import { ··· 34 35 CommandItem, 35 36 CommandList, 36 37 Dialog, 37 - DialogContent, 38 - DialogDescription, 39 - DialogHeader, 40 - DialogTitle, 41 38 DialogTrigger, 42 39 Form, 43 40 FormControl, ··· 67 64 import { LoadingAnimation } from "@/components/loading-animation"; 68 65 import { FailedPingAlertConfirmation } from "@/components/modals/failed-ping-alert-confirmation"; 69 66 import type { RegionChecker } from "@/components/ping-response-analysis/utils"; 70 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 71 67 import { toastAction } from "@/lib/toast"; 72 68 import { cn } from "@/lib/utils"; 73 69 import { api } from "@/trpc/client"; 74 - import type { Writeable } from "@/types/utils"; 75 - import { NotificationForm } from "./notification-form"; 76 70 77 71 const cronJobs = [ 78 72 { value: "30s", label: "30 seconds" },
-9
apps/web/src/components/forms/monitor/section-notifications.tsx
··· 8 8 Notification, 9 9 WorkspacePlan, 10 10 } from "@openstatus/db/src/schema"; 11 - import { getLimit } from "@openstatus/plans"; 12 11 import { 13 12 Badge, 14 - Button, 15 - Dialog, 16 - DialogContent, 17 - DialogDescription, 18 - DialogHeader, 19 - DialogTitle, 20 - DialogTrigger, 21 13 FormControl, 22 14 FormDescription, 23 15 FormField, ··· 26 18 FormMessage, 27 19 } from "@openstatus/ui"; 28 20 29 - import { NotificationForm } from "../notification-form"; 30 21 import { CheckboxLabel } from "../shared/checkbox-label"; 31 22 32 23 interface Props {
-284
apps/web/src/components/forms/notification-form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import { useMemo, useTransition } from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import type { 9 - InsertNotification, 10 - NotificationProvider, 11 - WorkspacePlan, 12 - } from "@openstatus/db/src/schema"; 13 - import { insertNotificationSchema } from "@openstatus/db/src/schema"; 14 - import { sendTestDiscordMessage } from "@openstatus/notification-discord"; 15 - import { sendTestSlackMessage } from "@openstatus/notification-slack"; 16 - import { 17 - Button, 18 - Form, 19 - FormControl, 20 - FormDescription, 21 - FormField, 22 - FormItem, 23 - FormLabel, 24 - FormMessage, 25 - Input, 26 - } from "@openstatus/ui"; 27 - 28 - import { LoadingAnimation } from "@/components/loading-animation"; 29 - import { toastAction } from "@/lib/toast"; 30 - import { toCapitalize } from "@/lib/utils"; 31 - import { api } from "@/trpc/client"; 32 - 33 - function getDefaultProviderData(defaultValues?: InsertNotification) { 34 - if (!defaultValues?.provider) return ""; // FIXME: input can empty - needs to be undefined 35 - return JSON.parse(defaultValues?.data || "{}")[defaultValues?.provider]; 36 - } 37 - 38 - function setProviderData(provider: NotificationProvider, data: string) { 39 - return { [provider]: data }; 40 - } 41 - 42 - function getProviderMetaData(provider: NotificationProvider) { 43 - switch (provider) { 44 - case "email": 45 - return { 46 - dataType: "email", 47 - placeholder: "dev@documenso.com", 48 - setupDocLink: null, 49 - sendTest: null, 50 - plans: ["free", "starter", "pro", "team"], 51 - }; 52 - 53 - case "slack": 54 - return { 55 - dataType: "url", 56 - placeholder: "https://hooks.slack.com/services/xxx...", 57 - setupDocLink: 58 - "https://api.slack.com/messaging/webhooks#getting_started", 59 - sendTest: sendTestSlackMessage, 60 - plans: ["free", "starter", "pro", "team"], 61 - }; 62 - 63 - case "discord": 64 - return { 65 - dataType: "url", 66 - placeholder: "https://discord.com/api/webhooks/{channelId}/xxx...", 67 - setupDocLink: "https://support.discord.com/hc/en-us/articles/228383668", 68 - sendTest: sendTestDiscordMessage, 69 - plans: ["free", "starter", "pro", "team"], 70 - }; 71 - case "sms": 72 - return { 73 - dataType: "tel", 74 - placeholder: "+123456789", 75 - setupDocLink: null, 76 - sendTest: null, 77 - plans: ["pro", "team"], 78 - }; 79 - case "pagerduty": 80 - return { 81 - dataType: null, 82 - placeholder: "", 83 - setupDocLink: 84 - "https://docs.openstatus.dev/synthetic/features/notification/pagerduty", 85 - sendTest: null, 86 - plans: ["starter", "pro", "team"], 87 - }; 88 - 89 - default: 90 - return { 91 - dataType: "url", 92 - placeholder: "xxxx", 93 - setupDocLink: `https://docs.openstatus.dev/integrations/${provider}`, 94 - send: null, 95 - plans: ["free", "starter", "pro", "team"], 96 - }; 97 - } 98 - } 99 - 100 - interface Props { 101 - defaultValues?: InsertNotification; 102 - onSubmit?: () => void; 103 - workspacePlan: WorkspacePlan; 104 - nextUrl?: string; 105 - provider: NotificationProvider; 106 - callbackData?: string; 107 - } 108 - 109 - export function NotificationForm({ 110 - defaultValues, 111 - onSubmit: onExternalSubmit, 112 - workspacePlan, 113 - nextUrl, 114 - provider, 115 - callbackData, 116 - }: Props) { 117 - const [isPending, startTransition] = useTransition(); 118 - const [isTestPending, startTestTransition] = useTransition(); 119 - const router = useRouter(); 120 - const form = useForm<InsertNotification>({ 121 - resolver: zodResolver(insertNotificationSchema), 122 - defaultValues: { 123 - ...defaultValues, 124 - provider, 125 - name: defaultValues?.name || "", 126 - data: getDefaultProviderData(defaultValues), 127 - }, 128 - }); 129 - const watchWebhookUrl = form.watch("data"); 130 - const providerMetaData = getProviderMetaData(provider); 131 - 132 - async function onSubmit({ provider, data, ...rest }: InsertNotification) { 133 - startTransition(async () => { 134 - try { 135 - if (provider === "pagerduty") { 136 - if (callbackData) { 137 - data = callbackData; 138 - } 139 - } 140 - if (data === "") { 141 - form.setError("data", { message: "This field is required" }); 142 - return; 143 - } 144 - if (defaultValues) { 145 - await api.notification.update.mutate({ 146 - provider, 147 - data: JSON.stringify(setProviderData(provider, data)), 148 - ...rest, 149 - }); 150 - } else { 151 - await api.notification.create.mutate({ 152 - provider, 153 - data: JSON.stringify(setProviderData(provider, data)), 154 - ...rest, 155 - }); 156 - } 157 - if (nextUrl) { 158 - router.push(nextUrl); 159 - } 160 - router.refresh(); 161 - toastAction("saved"); 162 - } catch { 163 - toastAction("error"); 164 - } finally { 165 - onExternalSubmit?.(); 166 - } 167 - }); 168 - } 169 - 170 - async function sendTestWebhookPing() { 171 - const webhookUrl = form.getValues("data"); 172 - if (!webhookUrl) return; 173 - startTestTransition(async () => { 174 - const isSuccessfull = await providerMetaData.sendTest?.(webhookUrl); 175 - if (isSuccessfull) { 176 - toastAction("test-success"); 177 - } else { 178 - toastAction("test-error"); 179 - } 180 - }); 181 - } 182 - 183 - return ( 184 - <Form {...form}> 185 - <form 186 - onSubmit={form.handleSubmit(onSubmit)} 187 - className="grid w-full gap-6" 188 - id="notification-form" // we use a form id to connect the submit button to the form (as we also have the form nested inside of `MonitorForm`) 189 - > 190 - <div className="grid gap-4 sm:grid-cols-3"> 191 - <div className="my-1.5 flex flex-col gap-2"> 192 - <p className="font-semibold text-sm leading-none">Alerts</p> 193 - <p className="text-muted-foreground text-sm"> 194 - Select the notification channels you want to be informed. 195 - </p> 196 - </div> 197 - <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 198 - <FormField 199 - control={form.control} 200 - name="name" 201 - render={({ field }) => ( 202 - <FormItem className="sm:col-span-1 sm:self-baseline"> 203 - <FormLabel>Name</FormLabel> 204 - <FormControl> 205 - <Input placeholder="Dev Team" {...field} /> 206 - </FormControl> 207 - <FormDescription> 208 - Define a name for the channel. 209 - </FormDescription> 210 - <FormMessage /> 211 - </FormItem> 212 - )} 213 - /> 214 - 215 - {providerMetaData.dataType && ( 216 - <FormField 217 - control={form.control} 218 - name="data" 219 - render={({ field }) => ( 220 - <FormItem className="sm:col-span-full"> 221 - {/* make the first letter capital */} 222 - <div className="flex items-center justify-between"> 223 - <FormLabel>{toCapitalize(provider)}</FormLabel> 224 - </div> 225 - <FormControl> 226 - <Input 227 - type={providerMetaData.dataType} 228 - placeholder={providerMetaData.placeholder} 229 - {...field} 230 - disabled={ 231 - !providerMetaData.plans?.includes(workspacePlan) 232 - } 233 - /> 234 - </FormControl> 235 - <FormDescription className="flex items-center justify-between"> 236 - The data required. 237 - {providerMetaData.setupDocLink && ( 238 - <a 239 - href={providerMetaData.setupDocLink} 240 - target="_blank" 241 - className="underline hover:no-underline" 242 - rel="noreferrer" 243 - > 244 - How to setup your {toCapitalize(provider)} webhook 245 - </a> 246 - )} 247 - </FormDescription> 248 - <FormMessage /> 249 - </FormItem> 250 - )} 251 - /> 252 - )} 253 - </div> 254 - </div> 255 - <div className="flex gap-4 sm:justify-end"> 256 - {providerMetaData.sendTest && ( 257 - <Button 258 - type="button" 259 - variant="secondary" 260 - className="w-full sm:w-auto" 261 - size="lg" 262 - disabled={!watchWebhookUrl || isTestPending} 263 - onClick={sendTestWebhookPing} 264 - > 265 - {!isTestPending ? ( 266 - "Test Webhook" 267 - ) : ( 268 - <LoadingAnimation variant="inverse" /> 269 - )} 270 - </Button> 271 - )} 272 - <Button 273 - form="notification-form" 274 - className="w-full sm:w-auto" 275 - size="lg" 276 - disabled={isPending} 277 - > 278 - {!isPending ? "Confirm" : <LoadingAnimation />} 279 - </Button> 280 - </div> 281 - </form> 282 - </Form> 283 - ); 284 - }
+11
apps/web/src/components/forms/notification/config.ts
··· 57 57 plans: plans.filter((plan) => allPlans[plan].limits.sms), 58 58 }; 59 59 60 + case "pagerduty": 61 + return { 62 + label: "PagerDuty", 63 + dataType: null, 64 + placeholder: "", 65 + setupDocLink: 66 + "https://docs.openstatus.dev/synthetic/features/notification/pagerduty", 67 + sendTest: null, 68 + plans: plans.filter((plan) => allPlans[plan].limits.pagerduty), 69 + }; 70 + 60 71 default: 61 72 return { 62 73 label: "Webhook",
+41 -46
apps/web/src/components/forms/notification/form.tsx
··· 2 2 3 3 import { zodResolver } from "@hookform/resolvers/zod"; 4 4 import { useRouter } from "next/navigation"; 5 - import { useMemo, useTransition } from "react"; 5 + import { useTransition } from "react"; 6 6 import { useForm } from "react-hook-form"; 7 7 8 8 import type { 9 9 InsertNotification, 10 + Monitor, 11 + NotificationProvider, 10 12 WorkspacePlan, 11 13 } from "@openstatus/db/src/schema"; 12 14 import { insertNotificationSchema } from "@openstatus/db/src/schema"; 13 - import { Button, Form } from "@openstatus/ui"; 15 + import { Badge, Form } from "@openstatus/ui"; 14 16 15 - import { LoadingAnimation } from "@/components/loading-animation"; 17 + import { 18 + Tabs, 19 + TabsContent, 20 + TabsList, 21 + TabsTrigger, 22 + } from "@/components/dashboard/tabs"; 16 23 import { toast, toastAction } from "@/lib/toast"; 17 24 import { api } from "@/trpc/client"; 18 - import { SchemaError } from "@openstatus/error"; 19 25 import { TRPCClientError } from "@trpc/client"; 20 - import { ZodError, type ZodIssue } from "zod"; 21 26 import { SaveButton } from "../shared/save-button"; 22 - import { 23 - getDefaultProviderData, 24 - getProviderMetaData, 25 - setProviderData, 26 - } from "./config"; 27 + import { getDefaultProviderData, setProviderData } from "./config"; 27 28 import { General } from "./general"; 29 + import { SectionConnect } from "./section-connect"; 28 30 29 31 interface Props { 30 32 defaultValues?: InsertNotification; 33 + defaultSection?: string; 31 34 onSubmit?: () => void; 35 + monitors?: Monitor[]; 32 36 workspacePlan: WorkspacePlan; 33 37 nextUrl?: string; 38 + provider: NotificationProvider; 39 + callbackData?: string; 34 40 } 35 41 36 42 export function NotificationForm({ 37 43 defaultValues, 44 + defaultSection = "connect", 38 45 onSubmit: onExternalSubmit, 39 46 workspacePlan, 47 + monitors, 40 48 nextUrl, 49 + provider, 50 + callbackData, 41 51 }: Props) { 42 52 const [isPending, startTransition] = useTransition(); 43 - const [isTestPending, startTestTransition] = useTransition(); 44 53 const router = useRouter(); 45 54 const form = useForm<InsertNotification>({ 46 55 resolver: zodResolver(insertNotificationSchema), 47 56 defaultValues: { 48 57 ...defaultValues, 58 + provider, 49 59 name: defaultValues?.name || "", 50 60 data: getDefaultProviderData(defaultValues), 51 61 }, 52 62 }); 53 - const watchProvider = form.watch("provider"); 54 - const watchWebhookUrl = form.watch("data"); 55 - const providerMetaData = useMemo( 56 - () => getProviderMetaData(watchProvider), 57 - [watchProvider], 58 - ); 59 63 60 64 async function onSubmit({ provider, data, ...rest }: InsertNotification) { 61 65 startTransition(async () => { 62 66 try { 67 + if (provider === "pagerduty") { 68 + if (callbackData) { 69 + data = callbackData; 70 + } 71 + } 63 72 if (data === "") { 64 73 form.setError("data", { message: "This field is required" }); 65 74 return; ··· 91 100 }); 92 101 } 93 102 94 - async function sendTestWebhookPing() { 95 - const webhookUrl = form.getValues("data"); 96 - if (!webhookUrl) return; 97 - startTestTransition(async () => { 98 - const isSuccessfull = await providerMetaData.sendTest?.(webhookUrl); 99 - if (isSuccessfull) { 100 - toastAction("test-success"); 101 - } else { 102 - toastAction("test-error"); 103 - } 104 - }); 105 - } 106 - 107 103 return ( 108 104 <Form {...form}> 109 105 <form ··· 112 108 className="flex flex-col gap-4" 113 109 > 114 110 <General form={form} plan={workspacePlan} /> 111 + <Tabs defaultValue={defaultSection} className="w-full"> 112 + <TabsList> 113 + <TabsTrigger value="connect"> 114 + Connect{" "} 115 + {defaultValues?.monitors.length ? ( 116 + <Badge variant="secondary" className="ml-1"> 117 + {defaultValues?.monitors.length} 118 + </Badge> 119 + ) : null} 120 + </TabsTrigger> 121 + </TabsList> 122 + <TabsContent value="connect"> 123 + <SectionConnect form={form} monitors={monitors} /> 124 + </TabsContent> 125 + </Tabs> 115 126 <div className="flex gap-4 sm:justify-end"> 116 - {providerMetaData.sendTest && ( 117 - <Button 118 - type="button" 119 - variant="secondary" 120 - className="w-full sm:w-auto" 121 - size="lg" 122 - disabled={!watchWebhookUrl || isTestPending} 123 - onClick={sendTestWebhookPing} 124 - > 125 - {!isTestPending ? ( 126 - "Test Webhook" 127 - ) : ( 128 - <LoadingAnimation variant="inverse" /> 129 - )} 130 - </Button> 131 - )} 132 127 <SaveButton 133 128 form="notification-form" 134 129 isPending={isPending}
+39 -55
apps/web/src/components/forms/notification/general.tsx
··· 1 1 "use client"; 2 2 3 - import { useMemo } from "react"; 3 + import { useMemo, useTransition } from "react"; 4 4 import type { UseFormReturn } from "react-hook-form"; 5 5 6 6 import type { ··· 8 8 WorkspacePlan, 9 9 } from "@openstatus/db/src/schema"; 10 10 import { 11 - notificationProvider, 12 - notificationProviderSchema, 13 - } from "@openstatus/db/src/schema"; 14 - import { 11 + Button, 15 12 FormControl, 16 13 FormDescription, 17 14 FormField, ··· 19 16 FormLabel, 20 17 FormMessage, 21 18 Input, 22 - Select, 23 - SelectContent, 24 - SelectItem, 25 - SelectTrigger, 26 - SelectValue, 27 19 } from "@openstatus/ui"; 28 20 29 21 import { SectionHeader } from "../shared/section-header"; 30 22 import { getProviderMetaData } from "./config"; 23 + import { toastAction } from "@/lib/toast"; 24 + import { LoadingAnimation } from "@/components/loading-animation"; 31 25 32 26 interface Props { 33 27 form: UseFormReturn<InsertNotification>; ··· 35 29 } 36 30 37 31 export function General({ form, plan }: Props) { 32 + const [isTestPending, startTestTransition] = useTransition(); 38 33 const watchProvider = form.watch("provider"); 34 + const watchWebhookUrl = form.watch("data"); 39 35 const providerMetaData = useMemo( 40 36 () => getProviderMetaData(watchProvider), 41 - [watchProvider], 37 + [watchProvider] 42 38 ); 43 39 40 + async function sendTestWebhookPing() { 41 + const webhookUrl = form.getValues("data"); 42 + if (!webhookUrl) return; 43 + startTestTransition(async () => { 44 + const isSuccessfull = await providerMetaData.sendTest?.(webhookUrl); 45 + if (isSuccessfull) { 46 + toastAction("test-success"); 47 + } else { 48 + toastAction("test-error"); 49 + } 50 + }); 51 + } 52 + 44 53 return ( 45 54 <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 46 55 <SectionHeader 47 56 title="Alert" 48 - description="Select the notification channels you want to be informed." 57 + description={`Update your ${providerMetaData.label} settings`} 49 58 /> 50 59 <div className="grid gap-4 sm:col-span-2 sm:grid-cols-2"> 51 60 <FormField 52 61 control={form.control} 53 - name="provider" 54 - render={({ field }) => ( 55 - <FormItem className="sm:col-span-1 sm:self-baseline"> 56 - <FormLabel>Provider</FormLabel> 57 - <Select 58 - onValueChange={(value) => 59 - field.onChange(notificationProviderSchema.parse(value)) 60 - } 61 - defaultValue={field.value} 62 - > 63 - <FormControl> 64 - <SelectTrigger className="capitalize"> 65 - <SelectValue placeholder="Select Provider" /> 66 - </SelectTrigger> 67 - </FormControl> 68 - <SelectContent> 69 - {notificationProvider.map((provider) => { 70 - const providerData = getProviderMetaData(provider); 71 - const enabled = providerData.plans?.includes(plan); 72 - 73 - return ( 74 - <SelectItem 75 - key={provider} 76 - value={provider} 77 - className="capitalize" 78 - disabled={!enabled} 79 - > 80 - {providerData.label} 81 - </SelectItem> 82 - ); 83 - })} 84 - </SelectContent> 85 - </Select> 86 - <FormDescription> 87 - What channel/provider to send a notification. 88 - </FormDescription> 89 - <FormMessage /> 90 - </FormItem> 91 - )} 92 - /> 93 - <FormField 94 - control={form.control} 95 62 name="name" 96 63 render={({ field }) => ( 97 64 <FormItem className="sm:col-span-1 sm:self-baseline"> ··· 104 71 </FormItem> 105 72 )} 106 73 /> 107 - {watchProvider && ( 74 + {providerMetaData.dataType && ( 108 75 <FormField 109 76 control={form.control} 110 77 name="data" ··· 137 104 )} 138 105 /> 139 106 )} 107 + <div className="col-span-full text-right"> 108 + {providerMetaData.sendTest && ( 109 + <Button 110 + type="button" 111 + variant="secondary" 112 + className="w-full sm:w-auto" 113 + disabled={!watchWebhookUrl || isTestPending} 114 + onClick={sendTestWebhookPing} 115 + > 116 + {!isTestPending ? ( 117 + "Test Webhook" 118 + ) : ( 119 + <LoadingAnimation variant="inverse" /> 120 + )} 121 + </Button> 122 + )} 123 + </div> 140 124 </div> 141 125 </div> 142 126 );
+88
apps/web/src/components/forms/notification/section-connect.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { UseFormReturn } from "react-hook-form"; 5 + 6 + import type { InsertNotification, Monitor } from "@openstatus/db/src/schema"; 7 + import { 8 + FormControl, 9 + FormDescription, 10 + FormField, 11 + FormItem, 12 + FormLabel, 13 + FormMessage, 14 + } from "@openstatus/ui"; 15 + 16 + import { CheckboxLabel } from "../shared/checkbox-label"; 17 + 18 + interface Props { 19 + form: UseFormReturn<InsertNotification>; 20 + monitors?: Monitor[]; 21 + } 22 + 23 + export function SectionConnect({ form, monitors }: Props) { 24 + return ( 25 + <div className="grid w-full gap-4"> 26 + <div className="flex flex-col gap-3"> 27 + <FormField 28 + control={form.control} 29 + name="monitors" 30 + render={() => ( 31 + <FormItem> 32 + <div className="mb-4"> 33 + <FormLabel>Monitors</FormLabel> 34 + <FormDescription> 35 + Attach the notification to specific monitors. 36 + </FormDescription> 37 + </div> 38 + <div className="grid grid-cols-1 grid-rows-1 gap-6 md:grid-cols-3 sm:grid-cols-2"> 39 + {monitors?.map((item) => ( 40 + <FormField 41 + key={item.id} 42 + control={form.control} 43 + name="monitors" 44 + render={({ field }) => { 45 + return ( 46 + <FormItem key={item.id} className="h-full w-full"> 47 + <FormControl className="w-full"> 48 + <CheckboxLabel 49 + id={String(item.id)} 50 + name="monitor" 51 + checked={field.value?.includes(item.id)} 52 + onCheckedChange={(checked) => { 53 + return checked 54 + ? field.onChange([ 55 + ...(field.value || []), 56 + item.id, 57 + ]) 58 + : field.onChange( 59 + field.value?.filter( 60 + (value) => value !== item.id 61 + ) 62 + ); 63 + }} 64 + className="flex-col items-start truncate" 65 + > 66 + <span>{item.name}</span> 67 + <span className="font-normal text-muted-foreground text-sm"> 68 + {item.url} 69 + </span> 70 + </CheckboxLabel> 71 + </FormControl> 72 + </FormItem> 73 + ); 74 + }} 75 + /> 76 + ))} 77 + </div> 78 + {!monitors || monitors.length === 0 ? ( 79 + <FormDescription>Missing monitors.</FormDescription> 80 + ) : null} 81 + <FormMessage /> 82 + </FormItem> 83 + )} 84 + /> 85 + </div> 86 + </div> 87 + ); 88 + }
+4 -1
apps/web/src/components/monitor-charts/chart.tsx
··· 77 77 className={cn("flex flex-1 gap-2", !isActive && "opacity-60")} 78 78 > 79 79 <div 80 - className={`bg- flex w-1 flex-col${category.color}-500 rounded`} 80 + className={cn( 81 + "flex w-1 flex-col rounded", 82 + `bg-${category.color}-500` 83 + )} 81 84 /> 82 85 <div className="flex w-full justify-between gap-2"> 83 86 <p className="shrink-0 text-tremor-content dark:text-dark-tremor-content">
+5 -1
apps/web/src/components/monitor-charts/simple-chart.tsx
··· 4 4 import { LineChart } from "@tremor/react"; 5 5 6 6 import { dataFormatter } from "./utils"; 7 + import { cn } from "@/lib/utils"; 7 8 8 9 export interface SimpleChartProps { 9 10 data: { timestamp: string; [key: string]: string }[]; ··· 48 49 // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 49 50 <div key={idx} className="flex flex-1 gap-2"> 50 51 <div 51 - className={`bg- flex w-1 flex-col${category.color}-500 rounded`} 52 + className={cn( 53 + "flex w-1 flex-col rounded", 54 + `bg-${category.color}-500` 55 + )} 52 56 /> 53 57 <div className="flex flex-col gap-1"> 54 58 <p className="text-tremor-content dark:text-dark-tremor-content">
+1 -1
apps/web/src/lib/auth/index.ts
··· 100 100 }, 101 101 pages: { 102 102 signIn: "/app/login", 103 - // newUser: "/app/onboarding", // TODO: rethink this as we still have the `slug` to use 103 + newUser: "/app/onboarding", 104 104 }, 105 105 // basePath: "/api/auth", // default is `/api/auth` 106 106 // secret: process.env.AUTH_SECRET, // default is `AUTH_SECRET`
+1
apps/web/src/middleware.ts
··· 39 39 "/app/sign-up", 40 40 "/app/login", 41 41 "/app/invite", 42 + "/app/onboarding", 42 43 ]; 43 44 44 45 // remove auth middleware if needed
+91 -16
packages/api/src/router/notification.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 2 import { z } from "zod"; 3 3 4 - import { and, eq } from "@openstatus/db"; 4 + import { and, eq, inArray } from "@openstatus/db"; 5 5 import { 6 6 NotificationDataSchema, 7 7 insertNotificationSchema, 8 + monitor, 8 9 notification, 10 + notificationsToMonitors, 9 11 selectMonitorSchema, 10 12 selectNotificationSchema, 11 13 } from "@openstatus/db/src/schema"; ··· 20 22 create: protectedProcedure 21 23 .input(insertNotificationSchema) 22 24 .mutation(async (opts) => { 23 - const { ...props } = opts.input; 25 + const { monitors, ...props } = opts.input; 24 26 25 27 const notificationLimit = getLimit( 26 28 opts.ctx.workspace.plan, ··· 55 57 .returning() 56 58 .get(); 57 59 60 + const values = monitors.map((monitorId) => ({ 61 + notificationId: _notification.id, 62 + monitorId, 63 + })); 64 + 65 + if (values.length) { 66 + await opts.ctx.db.insert(notificationsToMonitors).values(values); 67 + } 68 + 58 69 if (env.JITSU_HOST !== undefined && env.JITSU_WRITE_KEY !== undefined) { 59 70 await trackNewNotification(opts.ctx.user, { 60 71 provider: _notification.provider, ··· 69 80 .mutation(async (opts) => { 70 81 if (!opts.input.id) return; 71 82 72 - const { ...props } = opts.input; 83 + const { monitors, ...props } = opts.input; 73 84 74 85 const _data = NotificationDataSchema.safeParse(JSON.parse(props.data)); 75 86 ··· 80 91 }); 81 92 } 82 93 83 - return await opts.ctx.db 94 + const currentNotification = await opts.ctx.db 84 95 .update(notification) 85 96 .set({ ...props, updatedAt: new Date() }) 86 97 .where( ··· 91 102 ) 92 103 .returning() 93 104 .get(); 105 + 106 + // TODO: relation 107 + 108 + if (monitors.length) { 109 + const allMonitors = await opts.ctx.db.query.monitor.findMany({ 110 + where: and( 111 + eq(monitor.workspaceId, opts.ctx.workspace.id), 112 + inArray(monitor.id, monitors), 113 + ), 114 + }); 115 + 116 + if (allMonitors.length !== monitors.length) { 117 + throw new TRPCError({ 118 + code: "FORBIDDEN", 119 + message: "You don't have access to all the monitors.", 120 + }); 121 + } 122 + } 123 + 124 + const currentMonitorsToNotifications = await opts.ctx.db 125 + .select() 126 + .from(notificationsToMonitors) 127 + .where( 128 + eq(notificationsToMonitors.notificationId, currentNotification.id), 129 + ) 130 + .all(); 131 + 132 + const removedMonitors = currentMonitorsToNotifications 133 + .map(({ monitorId }) => monitorId) 134 + .filter((x) => !monitors?.includes(x)); 135 + 136 + if (removedMonitors.length) { 137 + await opts.ctx.db 138 + .delete(notificationsToMonitors) 139 + .where( 140 + and( 141 + inArray(notificationsToMonitors.monitorId, removedMonitors), 142 + eq( 143 + notificationsToMonitors.notificationId, 144 + currentNotification.id, 145 + ), 146 + ), 147 + ); 148 + } 149 + 150 + const values = monitors.map((monitorId) => ({ 151 + notificationId: currentNotification.id, 152 + monitorId, 153 + })); 154 + 155 + if (values.length) { 156 + await opts.ctx.db 157 + .insert(notificationsToMonitors) 158 + .values(values) 159 + .onConflictDoNothing(); 160 + } 161 + 162 + return currentNotification; 94 163 }), 95 164 96 165 deleteNotification: protectedProcedure ··· 110 179 getNotificationById: protectedProcedure 111 180 .input(z.object({ id: z.number() })) 112 181 .query(async (opts) => { 113 - const _notification = await opts.ctx.db 114 - .select() 115 - .from(notification) 116 - .where( 117 - and( 118 - eq(notification.id, opts.input.id), 119 - eq(notification.id, opts.input.id), 120 - eq(notification.workspaceId, opts.ctx.workspace.id), 121 - ), 122 - ) 123 - .get(); 182 + const _notification = await opts.ctx.db.query.notification.findFirst({ 183 + where: and( 184 + eq(notification.id, opts.input.id), 185 + eq(notification.id, opts.input.id), 186 + eq(notification.workspaceId, opts.ctx.workspace.id), 187 + ), 188 + // FIXME: plural 189 + with: { monitor: { with: { monitor: true } } }, 190 + }); 124 191 125 - return selectNotificationSchema.parse(_notification); 192 + const schema = selectNotificationSchema.extend({ 193 + monitor: z.array( 194 + z.object({ 195 + monitor: selectMonitorSchema, 196 + }), 197 + ), 198 + }); 199 + 200 + return schema.parse(_notification); 126 201 }), 127 202 128 203 getNotificationsByWorkspace: protectedProcedure.query(async (opts) => {
+4 -3
packages/db/src/schema/notifications/validation.ts
··· 13 13 return String(val); 14 14 }, z.string()) 15 15 .default("{}"), 16 - } 16 + }, 17 17 ); 18 18 19 19 // we need to extend, otherwise data can be `null` or `undefined` - default is not 20 20 export const insertNotificationSchema = createInsertSchema(notification).extend( 21 21 { 22 22 data: z.string().default("{}"), 23 - } 23 + monitors: z.array(z.number()).optional().default([]), 24 + }, 24 25 ); 25 26 26 27 export type InsertNotification = z.infer<typeof insertNotificationSchema>; ··· 28 29 export type NotificationProvider = z.infer<typeof notificationProviderSchema>; 29 30 30 31 const phoneRegex = new RegExp( 31 - /^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/ 32 + /^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/, 32 33 ); 33 34 34 35 export const phoneSchema = z.string().regex(phoneRegex, "Invalid Number!");