From e077444c9b1640ab0e4e3a37213b3b5ae3afe4e2 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:22:06 +0200 Subject: [PATCH] chore: improve incidents communication section (#2876) --- .../app/(dashboard)/incidents/[id]/client.tsx | 34 +- .../src/components/content/property-list.tsx | 25 +- .../status-report-update/sheet-create.tsx | 27 +- .../incidents/incident-communication.tsx | 333 +++++++++++++ .../incidents/incident-status-report.tsx | 454 ------------------ 5 files changed, 362 insertions(+), 511 deletions(-) create mode 100644 apps/dashboard/src/components/incidents/incident-communication.tsx delete mode 100644 apps/dashboard/src/components/incidents/incident-status-report.tsx diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index a766184c2..85cf6295f 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -6,7 +6,6 @@ import { useQuery } from "@tanstack/react-query"; import { formatDistanceStrict, formatDistanceToNow } from "date-fns"; import { useState } from "react"; -import { Link } from "@/components/common/link"; import { DetailAside, DetailContent, @@ -22,32 +21,22 @@ import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; -import { - Property, - PropertyLabel, - PropertyList, - PropertyValue, -} from "@/components/content/property-list"; import { SectionGroup } from "@/components/content/section"; import { Timeline } from "@/components/content/timeline"; import { IncidentActions, hasIncidentActions, } from "@/components/incidents/incident-actions"; +import { IncidentCommunication } from "@/components/incidents/incident-communication"; import { IncidentComposer } from "@/components/incidents/incident-composer"; import { IncidentHeading } from "@/components/incidents/incident-heading"; import { IncidentPostmortem } from "@/components/incidents/incident-postmortem"; import { IncidentProperties } from "@/components/incidents/incident-properties"; -import { IncidentStatusReport } from "@/components/incidents/incident-status-report"; import { IncidentTimelineItem } from "@/components/incidents/incident-timeline"; import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog"; import { incidentEndedAt } from "@/data/managed-incidents.client"; import { useTRPC } from "@/lib/trpc/client"; -function slackChannelUrl(teamId: string, channelId: string): string { - return `https://slack.com/app_redirect?team=${teamId}&channel=${channelId}`; -} - export function Client({ id }: { id: number }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); @@ -187,26 +176,7 @@ export function Client({ id }: { id: number }) { Communication - - - - Slack - - {incident.slackTeamId && incident.slackChannelId ? ( - - Open channel - - ) : ( - No channel - )} - - - + diff --git a/apps/dashboard/src/components/content/property-list.tsx b/apps/dashboard/src/components/content/property-list.tsx index 1f07b9668..808bfb7d5 100644 --- a/apps/dashboard/src/components/content/property-list.tsx +++ b/apps/dashboard/src/components/content/property-list.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight } from "@openstatus/icons"; +import { ArrowUpRight, ChevronDown } from "@openstatus/icons"; import { Input } from "@openstatus/ui/components/ui/input"; import { SelectTrigger } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; @@ -121,6 +121,29 @@ export function PropertySelectTrigger({ ); } +// Same chrome as PropertySelectTrigger, for rows that open a menu instead. +export function PropertyMenuTrigger({ + children, + className, + ...props +}: React.ComponentProps<"button">) { + return ( + + ); +} + // The native date picker icon is pinned right to line up with select chevrons. export function PropertyInput({ className, diff --git a/apps/dashboard/src/components/forms/status-report-update/sheet-create.tsx b/apps/dashboard/src/components/forms/status-report-update/sheet-create.tsx index 6eaf03632..2df621b8b 100644 --- a/apps/dashboard/src/components/forms/status-report-update/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/status-report-update/sheet-create.tsx @@ -2,11 +2,10 @@ import type { RouterOutputs } from "@openstatus/api"; import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; +import { usePublishUpdate } from "@/components/status-reports/use-publish-update"; import { getNextStatus } from "@/data/status-report-updates.client"; -import { useTRPC } from "@/lib/trpc/client"; type StatusReport = RouterOutputs["statusReport"]["list"][number]; @@ -22,8 +21,6 @@ export function FormSheetStatusReportUpdateCreate({ open?: boolean; onOpenChange?: (open: boolean) => void; }) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); const reportComponents = report.pageComponents?.map((c) => ({ id: c.id, name: c.name })) ?? []; const reportHasImpacts = report.updates.some( @@ -31,25 +28,7 @@ export function FormSheetStatusReportUpdateCreate({ ); const currentImpacts = currentImpactsFromUpdates(report.updates); const nextStatus = getNextStatus(report.status); - const sendStatusReportUpdateMutation = useMutation( - trpc.subscriberNotification.statusReport.mutationOptions(), - ); - const createStatusReportUpdateMutation = useMutation( - trpc.statusReport.createStatusReportUpdate.mutationOptions({ - onSuccess: (update) => { - if (update?.notifySubscribers) { - sendStatusReportUpdateMutation.mutate({ id: update.id }); - } - // no-input prefix key — matches every statusReport.list query - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.page.list.queryKey(), - }); - }, - }), - ); + const update = usePublishUpdate(report.id); return ( ci.impact !== "operational"); - await createStatusReportUpdateMutation.mutateAsync({ + await update.publish({ statusReportId: report.id, message: values.message, status: values.status, diff --git a/apps/dashboard/src/components/incidents/incident-communication.tsx b/apps/dashboard/src/components/incidents/incident-communication.tsx new file mode 100644 index 000000000..0858770ae --- /dev/null +++ b/apps/dashboard/src/components/incidents/incident-communication.tsx @@ -0,0 +1,333 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Add, ArrowUpRight, Linked, Send, Unlinked } from "@openstatus/icons"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@openstatus/ui/components/ui/alert-dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@openstatus/ui/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@openstatus/ui/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@openstatus/ui/components/ui/popover"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import NextLink from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Link } from "@/components/common/link"; +import { StatusDot } from "@/components/common/status-dot"; +import { + Property, + PropertyLabel, + PropertyList, + PropertyMenuTrigger, + PropertyValue, +} from "@/components/content/property-list"; +import { FormSheetStatusReportUpdateCreate } from "@/components/forms/status-report-update/sheet-create"; +import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; +import { statusVariants } from "@/data/status-report-updates.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { errorMessage } from "@/lib/trpc/error"; + +type Incident = NonNullable; +type Report = NonNullable; + +function slackChannelUrl(teamId: string, channelId: string): string { + return `https://slack.com/app_redirect?team=${teamId}&channel=${channelId}`; +} + +export function IncidentCommunication({ incident }: { incident: Incident }) { + const report = incident.statusReport; + return ( + + {report ? ( + + ) : ( + + )} + + Slack + + {incident.slackTeamId && incident.slackChannelId ? ( + + Open channel + + ) : ( + No channel + )} + + + + ); +} + +function LinkedReport({ + incident, + report, +}: { + incident: Incident; + report: Report; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [composing, setComposing] = useState(false); + const [confirmUnlink, setConfirmUnlink] = useState(false); + const unlink = useMutation( + trpc.incident.unlinkStatusReport.mutationOptions({ + onSuccess: () => { + setConfirmUnlink(false); + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.incident.get.queryKey({ id: incident.id }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.incident.linkedStatusReportIds.queryKey(), + }), + ]); + }, + onError: (error) => toast.error(errorMessage(error)), + }), + ); + const closed = incident.closedAt !== null; + // The sheet needs components and past updates, which the incident + // embeds only in part: fetched once the operator asks for it. + const { data: fullReport } = useQuery({ + ...trpc.statusReport.get.queryOptions({ id: report.id }), + enabled: composing, + }); + + return ( + + Status report + + + + + + {report.title} + + + + setComposing(true)}> + + Post public update + + {report.pageId ? ( + + + + Open report + + + ) : null} + {closed ? null : ( + <> + + setConfirmUnlink(true)} + > + + Unlink + + + )} + + + + {composing && fullReport ? ( + + ) : null} + + + + Unlink this status report? + + {report.title} stays on your status page as it is, but updates + from this incident no longer reach it. You can link it again + later. + + + + Keep linked + { + e.preventDefault(); + unlink.mutate({ id: incident.id }); + }} + > + Unlink + + + + + + ); +} + +function UnlinkedReport({ incident }: { incident: Incident }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [linking, setLinking] = useState(false); + const closed = incident.closedAt !== null; + + const refresh = () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.incident.get.queryKey({ id: incident.id }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.incident.listEvents.queryKey({ id: incident.id }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.incident.linkedStatusReportIds.queryKey(), + }), + ]); + const link = useMutation( + trpc.incident.linkStatusReport.mutationOptions({ onSuccess: refresh }), + ); + const { data: reports } = useQuery({ + ...trpc.statusReport.list.queryOptions({ order: "desc" }), + enabled: !closed, + }); + const { data: linkedIds } = useQuery({ + ...trpc.incident.linkedStatusReportIds.queryOptions(), + enabled: !closed, + }); + // Until both load, the list could offer reports another incident holds. + const linked = linkedIds ? new Set(linkedIds) : null; + const linkable = + reports && linked ? reports.filter((r) => !linked.has(r.id)) : []; + + function linkReport(statusReportId: number) { + setLinking(false); + toast.promise(link.mutateAsync({ id: incident.id, statusReportId }), { + loading: "Linking...", + success: "Status report linked", + error: (error) => errorMessage(error), + }); + } + + return ( + + Status report + + {closed ? ( + Not published + ) : linking ? ( + !open && setLinking(false)}> + + + Search status reports... + + + + + + + No status report found. + + {linkable.map((r) => ( + linkReport(r.id)} + > + + {r.title} + + {r.page.title} + + + ))} + + + + + + ) : ( + + + + Not published + + + + + Nothing here is public. Customers only see what you publish on + your status page. + + + setCreateOpen(true)}> + + Create status report + + {linkable.length > 0 ? ( + setLinking(true)}> + + Link existing + + ) : null} + + + )} + + {/* Mounted on demand: the sheet fetches pages as soon as it renders. */} + {createOpen ? ( + refresh().catch(console.error)} + /> + ) : null} + + ); +} diff --git a/apps/dashboard/src/components/incidents/incident-status-report.tsx b/apps/dashboard/src/components/incidents/incident-status-report.tsx deleted file mode 100644 index a53f50b00..000000000 --- a/apps/dashboard/src/components/incidents/incident-status-report.tsx +++ /dev/null @@ -1,454 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@openstatus/api"; -import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; -import { Check, Expand } from "@openstatus/icons"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@openstatus/ui/components/ui/alert-dialog"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@openstatus/ui/components/ui/command"; -import { Label } from "@openstatus/ui/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@openstatus/ui/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@openstatus/ui/components/ui/select"; -import { Textarea } from "@openstatus/ui/components/ui/textarea"; -import { cn } from "@openstatus/ui/lib/utils"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Link } from "@/components/common/link"; -import { StatusDot } from "@/components/common/status-dot"; -import { - ActionCard, - ActionCardContent, - ActionCardDescription, - ActionCardFooter, - ActionCardHeader, - ActionCardTitle, -} from "@/components/content/action-card"; -import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; -import { usePublishUpdate } from "@/components/status-reports/use-publish-update"; -import { statusVariants } from "@/data/status-report-updates.client"; -import { useTRPC } from "@/lib/trpc/client"; -import { errorMessage } from "@/lib/trpc/error"; - -type Incident = NonNullable; - -export function IncidentStatusReport({ - incident, - canNotify, -}: { - incident: Incident; - canNotify: boolean; -}) { - if (incident.statusReport) { - return ( - - ); - } - return ; -} - -function NotifySubscribers({ - id, - checked, - onCheckedChange, -}: { - id: string; - checked: boolean; - onCheckedChange: (checked: boolean) => void; -}) { - return ( -
- onCheckedChange(value === true)} - /> - -
- ); -} - -function LinkedReport({ - incident, - report, - canNotify, -}: { - incident: Incident; - report: NonNullable; - canNotify: boolean; -}) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [composing, setComposing] = useState(false); - const [confirmUnlink, setConfirmUnlink] = useState(false); - const [status, setStatus] = useState( - report.status === "resolved" ? "monitoring" : report.status, - ); - const [message, setMessage] = useState(""); - const [notifySubscribers, setNotifySubscribers] = useState(canNotify); - const update = usePublishUpdate(report.id); - const unlink = useMutation( - trpc.incident.unlinkStatusReport.mutationOptions({ - onSuccess: () => { - setConfirmUnlink(false); - return Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.incident.get.queryKey({ id: incident.id }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.incident.linkedStatusReportIds.queryKey(), - }), - ]); - }, - onError: (error) => toast.error(errorMessage(error)), - }), - ); - const closed = incident.closedAt !== null; - - async function post() { - const parsed = statusReportStatus.find((s) => s === status); - if (!parsed) return; - const promise = update.publish({ - statusReportId: report.id, - status: parsed, - message, - date: new Date(), - notifySubscribers, - }); - toast.promise(promise, { - loading: "Posting public update...", - success: "Public update posted", - error: (error) => errorMessage(error), - }); - await promise; - setMessage(""); - setComposing(false); - } - - return ( - - - - Published - - - {report.status} - - - - {report.pageId ? ( - - {report.title} - - ) : ( - {report.title} - )}{" "} - is what customers see on your status page. - - - {composing ? ( - - -