From fade9bc1841b46a467298732dd1c49797e486ea5 Mon Sep 17 00:00:00 2001 From: Moulik Aggarwal Date: Fri, 6 Feb 2026 20:53:34 +0530 Subject: [PATCH] Added status report update form (#1802) * Added status report update form Signed-off-by: Moulik Aggarwal * Updated css for status report update Signed-off-by: Moulik Aggarwal * Updated UI Signed-off-by: Moulik Aggarwal * fix: breadcrumb * Added form card review Signed-off-by: Moulik Aggarwal * chore: add affected components --------- Signed-off-by: Moulik Aggarwal Co-authored-by: Maximilian Kaske --- apps/dashboard/next.config.ts | 1 + .../(dashboard)/monitors/[id]/breadcrumb.tsx | 11 +- .../status-pages/[id]/breadcrumb.tsx | 14 +- .../[id]/status-reports/[reportId]/layout.tsx | 19 + .../[id]/status-reports/[reportId]/page.tsx | 135 ++++++ .../data-table/status-reports/columns.tsx | 15 + .../form-status-report.tsx | 402 ++++++++++++++++++ .../src/components/nav/nav-breadcrumb.tsx | 45 +- packages/api/src/router/statusReport.ts | 39 ++ 9 files changed, 671 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx create mode 100644 apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 2dccc7ffd..249c7d2ea 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -7,6 +7,7 @@ const nextConfig: NextConfig = { remotePatterns: [ new URL("https://openstatus.dev/**"), new URL("https://**.public.blob.vercel-storage.com/**"), + new URL("https://www.openstatus.dev/**"), ], }, logging: { diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx index 1e039800d..9a3330bec 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx @@ -4,10 +4,11 @@ import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { Activity, Cog, LayoutGrid, Logs, Siren } from "lucide-react"; -import { useParams } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; export function Breadcrumb() { const { id } = useParams<{ id: string }>(); + const pathname = usePathname(); const trpc = useTRPC(); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), @@ -19,7 +20,13 @@ export function Breadcrumb() { (); + const pathname = usePathname(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), @@ -31,7 +32,16 @@ export function Breadcrumb() { href: "/status-pages", icon: PanelTop, }, - { type: "page", label: statusPage.title }, + pathname === `/status-pages/${id}/status-reports` + ? { + type: "page", + label: statusPage.title, + } + : { + type: "link", + label: statusPage.title, + href: `/status-pages/${id}/status-reports`, + }, { type: "select", items: [ diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx new file mode 100644 index 000000000..58900778e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx @@ -0,0 +1,19 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string; reportId: string }>; +}) { + const { id, reportId } = await params; + const queryClient = getQueryClient(); + await queryClient.prefetchQuery( + trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), + ); + await queryClient.prefetchQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + return {children}; +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx new file mode 100644 index 000000000..265571e31 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { + EmptyStateContainer, + EmptyStateDescription, +} from "@/components/content/empty-state"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionHeaderRow, + SectionTitle, +} from "@/components/content/section"; +import { FormCardGroup } from "@/components/forms/form-card"; +import { FormSheetWithDirtyProtection } from "@/components/forms/form-sheet"; +import type { FormValues } from "@/components/forms/status-report-update/form"; +import { FormStatusReportUpdateCard } from "@/components/forms/status-report-update/form-status-report"; +import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { reportId } = useParams<{ id: string; reportId: string }>(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: statusReport, refetch } = useQuery( + trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), + ); + + const sendStatusReportUpdateMutation = useMutation( + trpc.emailRouter.sendStatusReport.mutationOptions(), + ); + + const createStatusReportUpdateMutation = useMutation( + trpc.statusReport.createStatusReportUpdate.mutationOptions({ + onSuccess: (update) => { + if (update?.notifySubscribers) { + sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + } + refetch(); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + + const updateStatusReportUpdateMutation = useMutation( + trpc.statusReport.updateStatusReportUpdate.mutationOptions({ + onSuccess: () => { + refetch(); + }, + }), + ); + + if (!statusReport) return null; + + const updates = [...statusReport.updates].sort( + (a, b) => b.date.getTime() - a.date.getTime(), + ); + + const affected = statusReport.pageComponents + .map((component) => component.name) + .join(", "); + + return ( + +
+ + + {statusReport.title} + + Manage updates for this status report. Affects{" "} + + {affected ? affected : "zero"} + {" "} + component(s). + + + + + + Status Page Report + { + await createStatusReportUpdateMutation.mutateAsync({ + statusReportId: statusReport.id, + message: values.message, + status: values.status, + date: values.date, + notifySubscribers: values.notifySubscribers, + }); + }} + > + + + + + + {updates.map((update, index) => ( + + { + await updateStatusReportUpdateMutation.mutateAsync({ + id: update.id, + statusReportId: statusReport.id, + message: values.message, + status: values.status, + date: values.date, + }); + }} + /> + + ))} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/status-reports/columns.tsx b/apps/dashboard/src/components/data-table/status-reports/columns.tsx index 6417c2047..fe178f643 100644 --- a/apps/dashboard/src/components/data-table/status-reports/columns.tsx +++ b/apps/dashboard/src/components/data-table/status-reports/columns.tsx @@ -2,6 +2,7 @@ import { TableCellBadge } from "@/components/data-table/table-cell-badge"; import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { Button } from "@/components/ui/button"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; @@ -51,6 +52,20 @@ export const columns: ColumnDef[] = [ { accessorKey: "title", header: "Title", + cell: ({ row }) => { + const { id, pageId } = row.original; + + return ( + { + // avoid expanding the row + e.stopPropagation(); + }} + value={row.getValue("title")} + /> + ); + }, enableSorting: false, enableHiding: false, meta: { diff --git a/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx b/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx new file mode 100644 index 000000000..f2898f818 --- /dev/null +++ b/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx @@ -0,0 +1,402 @@ +"use client"; + +import { ProcessMessage } from "@/components/content/process-message"; +import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; +import { + FormCardContent, + FormCardSeparator, +} from "@/components/forms/form-card"; +import { + FormCard, + FormCardFooter, + FormCardHeader, + FormCardTitle, +} from "@/components/forms/form-card"; +import { useFormSheetDirty } from "@/components/forms/form-sheet"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Popover } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { TabsContent } from "@/components/ui/tabs"; +import { TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { colors } from "@/data/status-report-updates.client"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + type StatusReportUpdate, + statusReportStatus, +} from "@openstatus/db/src/schema"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { format } from "date-fns"; +import { CalendarIcon, ClockIcon } from "lucide-react"; +import { useParams } from "next/navigation"; +import React, { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const schema = z.object({ + status: z.enum(statusReportStatus), + message: z.string(), + date: z.date(), + notifySubscribers: z.boolean().optional(), +}); + +export type FormValues = z.infer; + +export function FormStatusReportUpdateCard({ + defaultValues, + onSubmit, + className, + index, + update, + ...props +}: Omit, "onSubmit"> & { + defaultValues?: FormValues; + onSubmit: (values: FormValues) => Promise; + index: number; + update: StatusReportUpdate; +}) { + const { id, reportId } = useParams<{ id: string; reportId: string }>(); + const trpc = useTRPC(); + const { data: workspace } = useQuery( + trpc.workspace.getWorkspace.queryOptions(), + ); + const mobile = useIsMobile(); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaultValues ?? { + status: "identified", + message: "", + date: new Date(), + notifySubscribers: true, + }, + }); + const watchMessage = form.watch("message"); + const [isPending, startTransition] = useTransition(); + const { setIsDirty } = useFormSheetDirty(); + + const formIsDirty = form.formState.isDirty; + React.useEffect(() => { + setIsDirty(formIsDirty); + }, [formIsDirty, setIsDirty]); + + const { data: statusReport, refetch } = useQuery( + trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), + ); + + function submitAction(values: FormValues) { + if (isPending) return; + + startTransition(async () => { + try { + const promise = onSubmit(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); + } + }); + } + + const deleteStatusReportUpdateMutation = useMutation( + trpc.statusReport.deleteUpdate.mutationOptions({ + onSuccess: () => { + refetch(); + }, + }), + ); + + const updates = [...(statusReport?.updates ?? [])].sort( + (a, b) => b.date.getTime() - a.date.getTime(), + ); + + return ( + + + + Status Report Update #{updates.length - index} + + + +
+ + + ( + + Status + + + + + + )} + /> + ( + + Date + + + + + + + + { + if (!selectedDate) return; + const newDate = new Date(selectedDate); + newDate.setHours( + field.value.getHours(), + field.value.getMinutes(), + field.value.getSeconds(), + field.value.getMilliseconds(), + ); + field.onChange(newDate); + }} + disabled={(date) => + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> +
+
+ +
+ { + try { + const timeValue = e.target.value; + if (!timeValue || !field.value) return; + const [hours, minutes, seconds] = timeValue + .split(":") + .map(Number); + const newDate = new Date(field.value); + newDate.setHours( + hours, + minutes, + seconds || 0, + 0, + ); + field.onChange(newDate); + } catch (error) { + console.error(error); + } + }} + /> +
+
+
+
+
+
+
+ +
+ )} + /> +
+ + + When the status report was created. Shown in your timezone ( + + {timezone} + + ) and saved as Unix time ( + UTC + ). + + + + + + + Writing + Preview + + + ( + + Message + +