Openstatus www.openstatus.dev

feat: status reports fit to design system (#528)

* feat: status reports fit to design system

* fix: remove logging

* chore: remove todo

authored by

Maximilian Kaske and committed by
GitHub
25a8f9a4 6ec68a03

+386 -186
+20
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/[id]/_components/empty-state.tsx
··· 1 + import Link from "next/link"; 2 + 3 + import { Button } from "@openstatus/ui"; 4 + 5 + import { EmptyState as DefaultEmptyState } from "@/components/dashboard/empty-state"; 6 + 7 + export function EmptyState({ id }: { id: string }) { 8 + return ( 9 + <DefaultEmptyState 10 + icon="megaphone" 11 + title="No status report updates" 12 + description="Create your first update" 13 + action={ 14 + <Button asChild> 15 + <Link href={`./${id}/update/edit`}>Create</Link> 16 + </Button> 17 + } 18 + /> 19 + ); 20 + }
+31
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/[id]/loading.tsx
··· 1 + import { Separator, Skeleton } from "@openstatus/ui"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + 5 + export default function Loading() { 6 + return ( 7 + <div className="grid gap-6 md:grid-cols-1 md:gap-8"> 8 + <div className="col-span-full flex w-full justify-between"> 9 + <Header.Skeleton withDescription={false}> 10 + <Skeleton className="h-9 w-20" /> 11 + </Header.Skeleton> 12 + </div> 13 + <div className="col-span-full flex flex-col gap-6"> 14 + <div className="grid grid-cols-5 gap-3 text-sm"> 15 + <Skeleton className="col-start-1 h-5 max-w-[100px]" /> 16 + <Skeleton className="col-span-4 h-5 w-full max-w-[200px]" /> 17 + <Skeleton className="col-start-1 h-5 max-w-[100px]" /> 18 + <Skeleton className="col-span-4 h-5 w-full max-w-[100px]" /> 19 + <Skeleton className="col-start-1 h-5 max-w-[100px]" /> 20 + <div className="col-span-4 flex gap-2"> 21 + <Skeleton className="h-5 w-full max-w-[60px]" /> 22 + <Skeleton className="h-5 w-full max-w-[60px]" /> 23 + </div> 24 + </div> 25 + <Separator /> 26 + <Skeleton className="h-48 w-full" /> 27 + <Skeleton className="h-48 w-full" /> 28 + </div> 29 + </div> 30 + ); 31 + }
+48
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/[id]/page.tsx
··· 1 + import * as React from "react"; 2 + import Link from "next/link"; 3 + import { notFound } from "next/navigation"; 4 + 5 + import { Button, Separator } from "@openstatus/ui"; 6 + 7 + import { Header } from "@/components/dashboard/header"; 8 + import { Events } from "@/components/status-update/events"; 9 + import { Summary } from "@/components/status-update/summary"; 10 + import { api } from "@/trpc/server"; 11 + import { EmptyState } from "./_components/empty-state"; 12 + import Loading from "./loading"; 13 + 14 + export default async function StatusReportsPage({ 15 + params, 16 + }: { 17 + params: { workspaceSlug: string; id: string }; 18 + }) { 19 + const report = await api.statusReport.getStatusReportById.query({ 20 + id: parseInt(params.id), 21 + }); 22 + 23 + if (!report) return notFound(); 24 + 25 + const monitors = report.monitorsToStatusReports.map(({ monitor }) => monitor); 26 + 27 + return ( 28 + <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-1 md:gap-8"> 29 + <Header 30 + title={report.title} 31 + actions={ 32 + <Button asChild> 33 + <Link href={`./${params.id}/update/edit`}>Update</Link> 34 + </Button> 35 + } 36 + /> 37 + <div className="col-span-full flex flex-col gap-6"> 38 + <Summary report={report} monitors={monitors} /> 39 + <Separator /> 40 + {report.statusReportUpdates.length > 0 ? ( 41 + <Events statusReportUpdates={report.statusReportUpdates} editable /> 42 + ) : ( 43 + <EmptyState id={params.id} /> 44 + )} 45 + </div> 46 + </div> 47 + ); 48 + }
+22 -17
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/_components/action-button.tsx apps/web/src/components/data-table/status-report/data-table-row-actions.tsx
··· 3 3 import * as React from "react"; 4 4 import Link from "next/link"; 5 5 import { useRouter } from "next/navigation"; 6 - import { MoreVertical } from "lucide-react"; 7 - import type * as z from "zod"; 6 + import type { Row } from "@tanstack/react-table"; 7 + import { MoreHorizontal } from "lucide-react"; 8 8 9 - import { insertStatusReportSchema } from "@openstatus/db/src/schema"; 9 + import { selectStatusReportSchema } from "@openstatus/db/src/schema"; 10 10 import { 11 11 AlertDialog, 12 12 AlertDialogAction, ··· 28 28 import { useToastAction } from "@/hooks/use-toast-action"; 29 29 import { api } from "@/trpc/client"; 30 30 31 - const temporary = insertStatusReportSchema.pick({ 32 - id: true, 33 - workspaceSlug: true, 34 - }); 35 - 36 - type Schema = z.infer<typeof temporary>; 31 + interface DataTableRowActionsProps<TData> { 32 + row: Row<TData>; 33 + } 37 34 38 - export function ActionButton(props: Schema) { 35 + export function DataTableRowActions<TData>({ 36 + row, 37 + }: DataTableRowActionsProps<TData>) { 38 + const statusReport = selectStatusReportSchema.parse(row.original); 39 39 const router = useRouter(); 40 40 const { toast } = useToastAction(); 41 41 const [alertOpen, setAlertOpen] = React.useState(false); 42 42 const [isPending, startTransition] = React.useTransition(); 43 43 44 - async function deeteStatusReport() { 44 + async function onDelete() { 45 45 startTransition(async () => { 46 46 try { 47 - if (!props.id) return; 48 - await api.statusReport.deleteStatusReport.mutate({ id: props.id }); 47 + if (!statusReport.id) return; 48 + await api.statusReport.deleteStatusReport.mutate({ 49 + id: statusReport.id, 50 + }); 49 51 toast("deleted"); 50 52 router.refresh(); 51 53 setAlertOpen(false); ··· 64 66 className="data-[state=open]:bg-accent h-8 w-8 p-0" 65 67 > 66 68 <span className="sr-only">Open menu</span> 67 - <MoreVertical className="h-4 w-4" /> 69 + <MoreHorizontal className="h-4 w-4" /> 68 70 </Button> 69 71 </DropdownMenuTrigger> 70 72 <DropdownMenuContent align="end"> 71 - <Link href={`./status-reports/edit?id=${props.id}`}> 73 + <Link href={`./status-reports/edit?id=${statusReport.id}`}> 72 74 <DropdownMenuItem>Edit</DropdownMenuItem> 75 + </Link> 76 + <Link href={`./status-reports/${statusReport.id}`}> 77 + <DropdownMenuItem>View</DropdownMenuItem> 73 78 </Link> 74 79 <AlertDialogTrigger asChild> 75 80 <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> ··· 83 88 <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 84 89 <AlertDialogDescription> 85 90 This action cannot be undone. This will permanently delete the 86 - status report. 91 + monitor. 87 92 </AlertDialogDescription> 88 93 </AlertDialogHeader> 89 94 <AlertDialogFooter> ··· 91 96 <AlertDialogAction 92 97 onClick={(e) => { 93 98 e.preventDefault(); 94 - deeteStatusReport(); 99 + onDelete(); 95 100 }} 96 101 disabled={isPending} 97 102 className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+4 -3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/loading.tsx
··· 1 1 import { Skeleton } from "@openstatus/ui"; 2 2 3 3 import { Header } from "@/components/dashboard/header"; 4 + import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 4 5 5 6 export default function Loading() { 6 7 return ( ··· 10 11 <Skeleton className="h-9 w-20" /> 11 12 </Header.Skeleton> 12 13 </div> 13 - <Skeleton className="h-4 w-24" /> 14 - <Skeleton className="h-48 w-full" /> 15 - <Skeleton className="h-48 w-full" /> 14 + <div className="col-span-full w-full"> 15 + <DataTableSkeleton /> 16 + </div> 16 17 </div> 17 18 ); 18 19 }
+3 -65
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/page.tsx
··· 1 1 import * as React from "react"; 2 2 import Link from "next/link"; 3 - import { formatDistance } from "date-fns"; 4 3 5 4 import { Button } from "@openstatus/ui"; 6 5 7 - import { Container } from "@/components/dashboard/container"; 8 6 import { Header } from "@/components/dashboard/header"; 9 7 import { HelpCallout } from "@/components/dashboard/help-callout"; 10 - import { Icons } from "@/components/icons"; 11 - import { AffectedMonitors } from "@/components/status-update/affected-monitors"; 12 - import { Events } from "@/components/status-update/events"; 13 - import { StatusBadge } from "@/components/status-update/status-badge"; 14 - import { statusDict } from "@/data/incidents-dictionary"; 8 + import { columns } from "@/components/data-table/status-report/columns"; 9 + import { DataTable } from "@/components/data-table/status-report/data-table"; 15 10 import { api } from "@/trpc/server"; 16 - import { ActionButton } from "./_components/action-button"; 17 11 import { EmptyState } from "./_components/empty-state"; 18 12 19 13 export default async function StatusReportsPage({ ··· 35 29 /> 36 30 {Boolean(reports?.length) ? ( 37 31 <div className="col-span-full"> 38 - <ul role="list" className="grid gap-4 sm:col-span-6"> 39 - {reports?.map((report, i) => { 40 - const { label, icon } = 41 - statusDict[report.status as keyof typeof statusDict]; 42 - const monitors = report.monitorsToStatusReports.map( 43 - ({ monitor }) => monitor, 44 - ); 45 - return ( 46 - <li key={i} className="grid gap-2"> 47 - <time className="text-muted-foreground pl-3 text-xs"> 48 - {formatDistance(new Date(report.createdAt!), new Date(), { 49 - addSuffix: true, 50 - })} 51 - </time> 52 - <Container 53 - title={ 54 - <span className="flex flex-wrap gap-2"> 55 - {report.title} 56 - <StatusBadge status={report.status} /> 57 - </span> 58 - } 59 - actions={[ 60 - <Button key="status-button" variant="outline" size="sm"> 61 - <Link 62 - href={`./status-reports/update/edit?id=${report.id}`} 63 - > 64 - New Update 65 - </Link> 66 - </Button>, 67 - <ActionButton key="action-button" id={report.id} />, 68 - ]} 69 - > 70 - <div className="grid gap-4"> 71 - {Boolean(monitors.length) ? ( 72 - <div> 73 - <p className="text-muted-foreground mb-1.5 text-xs"> 74 - Affected Monitors 75 - </p> 76 - <AffectedMonitors monitors={monitors} /> 77 - </div> 78 - ) : null} 79 - <div> 80 - <p className="text-muted-foreground mb-1.5 text-xs"> 81 - Last Updates 82 - </p> 83 - {/* Make it ordered by desc and make it toggable if you want the whole history! */} 84 - <Events 85 - statusReportUpdates={report.statusReportUpdates} 86 - editable 87 - /> 88 - </div> 89 - </div> 90 - </Container> 91 - </li> 92 - ); 93 - })} 94 - </ul> 32 + <DataTable columns={columns} data={reports} /> 95 33 </div> 96 34 ) : ( 97 35 <div className="col-span-full">
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/update/edit/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/[id]/update/edit/loading.tsx
+4 -5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/update/edit/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/[id]/update/edit/page.tsx
··· 9 9 * allowed URL search params 10 10 */ 11 11 const searchParamsSchema = z.object({ 12 - id: z.coerce.number(), 13 - statusUpdate: z.coerce.number().optional(), 12 + statusUpdate: z.coerce.number().optional(), // TODO: call it id as we do it everywhere else 14 13 }); 15 14 16 15 export default async function EditPage({ 17 16 params, 18 17 searchParams, 19 18 }: { 20 - params: { workspaceSlug: string }; 19 + params: { workspaceSlug: string; id: string }; 21 20 searchParams: { [key: string]: string | string[] | undefined }; 22 21 }) { 23 22 const search = searchParamsSchema.safeParse(searchParams); ··· 26 25 return notFound(); 27 26 } 28 27 29 - const { id, statusUpdate } = search.data; 28 + const { statusUpdate } = search.data; 30 29 31 30 const data = statusUpdate 32 31 ? await api.statusReport.getStatusReportUpdateById.query({ ··· 42 41 /> 43 42 <div className="col-span-full"> 44 43 <StatusReportUpdateForm 45 - statusReportId={id} 44 + statusReportId={parseInt(params.id)} 46 45 defaultValues={data || undefined} 47 46 /> 48 47 </div>
+8 -2
apps/web/src/components/dashboard/header.tsx
··· 32 32 ); 33 33 } 34 34 35 - function HeaderSkeleton({ children }: { children?: React.ReactNode }) { 35 + function HeaderSkeleton({ 36 + children, 37 + withDescription = true, 38 + }: { 39 + children?: React.ReactNode; 40 + withDescription?: boolean; 41 + }) { 36 42 return ( 37 43 <div className="col-span-full mr-12 flex w-full justify-between lg:mr-0"> 38 44 <div className="grid w-full gap-3"> 39 45 <Skeleton className="h-8 w-full max-w-[200px]" /> 40 - <Skeleton className="h-4 w-full max-w-[300px]" /> 46 + {withDescription && <Skeleton className="h-4 w-full max-w-[300px]" />} 41 47 </div> 42 48 {children} 43 49 </div>
+63
apps/web/src/components/data-table/status-report/columns.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import type { ColumnDef } from "@tanstack/react-table"; 5 + 6 + import type { 7 + StatusReport, 8 + StatusReportUpdate, 9 + } from "@openstatus/db/src/schema"; 10 + 11 + import { StatusBadge } from "@/components/status-update/status-badge"; 12 + import { formatDate } from "@/lib/utils"; 13 + import { DataTableRowActions } from "./data-table-row-actions"; 14 + 15 + export const columns: ColumnDef< 16 + StatusReport & { statusReportUpdates: StatusReportUpdate[] } 17 + >[] = [ 18 + { 19 + accessorKey: "title", 20 + header: "Title", 21 + cell: ({ row }) => { 22 + const id = row.original.id; 23 + return ( 24 + <Link href={`./status-reports/${id}`} className="hover:underline"> 25 + <span className="truncate">{row.getValue("title")}</span> 26 + </Link> 27 + ); 28 + }, 29 + }, 30 + { 31 + accessorKey: "status", 32 + header: "Status", 33 + cell: ({ row }) => { 34 + const status = row.original.status; 35 + return <StatusBadge status={status} />; 36 + }, 37 + }, 38 + { 39 + accessorKey: "statusReportUpdates", 40 + header: "Updates", 41 + cell: ({ row }) => { 42 + const statusReportUpdates = row.original.statusReportUpdates; 43 + return <code>{statusReportUpdates.length}</code>; 44 + }, 45 + }, 46 + { 47 + accessorKey: "updatedAt", 48 + header: "Last Updated", 49 + cell: ({ row }) => { 50 + return <span>{formatDate(row.getValue("updatedAt"))}</span>; 51 + }, 52 + }, 53 + { 54 + id: "actions", 55 + cell: ({ row }) => { 56 + return ( 57 + <div className="text-right"> 58 + <DataTableRowActions row={row} /> 59 + </div> 60 + ); 61 + }, 62 + }, 63 + ];
+81
apps/web/src/components/data-table/status-report/data-table.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { ColumnDef } from "@tanstack/react-table"; 5 + import { 6 + flexRender, 7 + getCoreRowModel, 8 + useReactTable, 9 + } from "@tanstack/react-table"; 10 + 11 + import { 12 + Table, 13 + TableBody, 14 + TableCell, 15 + TableHead, 16 + TableHeader, 17 + TableRow, 18 + } from "@openstatus/ui"; 19 + 20 + interface DataTableProps<TData, TValue> { 21 + columns: ColumnDef<TData, TValue>[]; 22 + data: TData[]; 23 + } 24 + 25 + export function DataTable<TData, TValue>({ 26 + columns, 27 + data, 28 + }: DataTableProps<TData, TValue>) { 29 + const table = useReactTable({ 30 + data, 31 + columns, 32 + getCoreRowModel: getCoreRowModel(), 33 + }); 34 + 35 + return ( 36 + <div className="rounded-md border"> 37 + <Table> 38 + <TableHeader className="bg-muted/50"> 39 + {table.getHeaderGroups().map((headerGroup) => ( 40 + <TableRow key={headerGroup.id} className="hover:bg-transparent"> 41 + {headerGroup.headers.map((header) => { 42 + return ( 43 + <TableHead key={header.id}> 44 + {header.isPlaceholder 45 + ? null 46 + : flexRender( 47 + header.column.columnDef.header, 48 + header.getContext(), 49 + )} 50 + </TableHead> 51 + ); 52 + })} 53 + </TableRow> 54 + ))} 55 + </TableHeader> 56 + <TableBody> 57 + {table.getRowModel().rows?.length ? ( 58 + table.getRowModel().rows.map((row) => ( 59 + <TableRow 60 + key={row.id} 61 + data-state={row.getIsSelected() && "selected"} 62 + > 63 + {row.getVisibleCells().map((cell) => ( 64 + <TableCell key={cell.id}> 65 + {flexRender(cell.column.columnDef.cell, cell.getContext())} 66 + </TableCell> 67 + ))} 68 + </TableRow> 69 + )) 70 + ) : ( 71 + <TableRow> 72 + <TableCell colSpan={columns.length} className="h-24 text-center"> 73 + No results. 74 + </TableCell> 75 + </TableRow> 76 + )} 77 + </TableBody> 78 + </Table> 79 + </div> 80 + ); 81 + }
+2
apps/web/src/components/forms/status-report-update-form.tsx
··· 70 70 } 71 71 toast("saved"); 72 72 router.refresh(); 73 + // TODO: temporary solution, we might wanna use a server-action with redirect 74 + router.push("../"); 73 75 } catch { 74 76 toast("error"); 75 77 }
+12 -30
apps/web/src/components/status-page/incident-list.tsx
··· 1 + "use client"; 2 + 1 3 import type { z } from "zod"; 2 4 3 5 import type { 4 6 selectPublicMonitorSchema, 5 7 selectStatusReportPageSchema, 6 8 } from "@openstatus/db/src/schema"; 9 + import { Separator } from "@openstatus/ui"; 7 10 8 11 import { notEmpty } from "@/lib/utils"; 9 - import { AffectedMonitors } from "../status-update/affected-monitors"; 10 12 import { Events } from "../status-update/events"; 11 - import { StatusBadge } from "../status-update/status-badge"; 13 + import { Summary } from "../status-update/summary"; 12 14 13 15 // TODO: change layout - it is too packed with data rn 14 16 ··· 45 47 {context === "all" ? "All incidents" : "Latest incidents"} 46 48 </h2> 47 49 {_incidents.map((incident) => { 48 - const affectedMonitors = incident.monitorsToStatusReport 50 + const affectedMonitors = incident.monitorsToStatusReports 49 51 .map(({ monitorId }) => { 50 52 const monitor = monitors.find(({ id }) => monitorId === id); 51 53 return monitor || undefined; ··· 53 55 .filter(notEmpty); 54 56 return ( 55 57 <div key={incident.id} className="grid gap-4 text-left"> 56 - <div className="max-w-3xl font-semibold"> 57 - {incident.title} 58 - <StatusBadge status={incident.status} className="ml-2" /> 59 - </div> 60 - {Boolean(affectedMonitors.length) ? ( 61 - <div className="overflow-hidden text-ellipsis"> 62 - <p className="text-muted-foreground mb-2 text-xs"> 63 - Affected Monitors 64 - </p> 65 - <AffectedMonitors 66 - monitors={incident.monitorsToStatusReport 67 - .map(({ monitorId }) => { 68 - const monitor = monitors.find( 69 - ({ id }) => monitorId === id, 70 - ); 71 - return monitor || undefined; 72 - }) 73 - .filter(notEmpty)} 74 - /> 75 - </div> 76 - ) : null} 77 - <div> 78 - <p className="text-muted-foreground mb-2 text-xs"> 79 - Latest Updates 80 - </p> 81 - <Events statusReportUpdates={incident.statusReportUpdates} /> 82 - </div> 58 + <div className="max-w-3xl font-semibold">{incident.title}</div> 59 + <Summary report={incident} monitors={affectedMonitors} /> 60 + <Separator /> 61 + <Events 62 + statusReportUpdates={incident.statusReportUpdates} 63 + collabsible 64 + /> 83 65 </div> 84 66 ); 85 67 })}
-24
apps/web/src/components/status-update/affected-monitors.tsx
··· 1 - import type * as z from "zod"; 2 - 3 - import type { selectPublicMonitorSchema } from "@openstatus/db/src/schema"; 4 - import { Badge } from "@openstatus/ui"; 5 - 6 - export function AffectedMonitors({ 7 - monitors, 8 - }: { 9 - monitors: z.infer<typeof selectPublicMonitorSchema>[]; 10 - }) { 11 - return ( 12 - <ul role="list" className="flex gap-2"> 13 - {monitors.length > 0 ? ( 14 - monitors.map(({ name }, i) => ( 15 - <li key={i} className="text-xs"> 16 - <Badge variant="secondary">{name}</Badge> 17 - </li> 18 - )) 19 - ) : ( 20 - <li className="text-muted-foreground text-sm">Monitor(s) missing</li> 21 - )} 22 - </ul> 23 - ); 24 - }
+23 -37
apps/web/src/components/status-update/events.tsx
··· 2 2 3 3 import * as React from "react"; 4 4 import { useRouter } from "next/navigation"; 5 - import { format, formatDistance } from "date-fns"; 6 - import type * as z from "zod"; 5 + import { format } from "date-fns"; 7 6 8 - import type { selectStatusReportUpdateSchema } from "@openstatus/db/src/schema"; 9 - import { 10 - Button, 11 - Tooltip, 12 - TooltipContent, 13 - TooltipProvider, 14 - TooltipTrigger, 15 - } from "@openstatus/ui"; 7 + import type { StatusReportUpdate } from "@openstatus/db/src/schema"; 8 + import { Button } from "@openstatus/ui"; 16 9 17 10 import { DeleteStatusReportUpdateButtonIcon } from "@/app/app/[workspaceSlug]/(dashboard)/status-reports/_components/delete-status-update"; 18 11 import { Icons } from "@/components/icons"; 19 12 import { statusDict } from "@/data/incidents-dictionary"; 20 13 import { useProcessor } from "@/hooks/use-preprocessor"; 21 14 import { cn } from "@/lib/utils"; 22 - 23 - type StatusReportUpdateProps = z.infer<typeof selectStatusReportUpdateSchema>; 24 15 25 16 export function Events({ 26 17 statusReportUpdates, 27 18 editable = false, 19 + collabsible = false, 28 20 }: { 29 - statusReportUpdates: StatusReportUpdateProps[]; 21 + statusReportUpdates: StatusReportUpdate[]; 30 22 editable?: boolean; 23 + collabsible?: boolean; 31 24 }) { 32 25 const [open, toggle] = React.useReducer((open) => !open, false); 33 26 const router = useRouter(); ··· 38 31 const orderB = statusDict[b.status].order; 39 32 return orderB - orderA; 40 33 }); 41 - const slicedArray = open 42 - ? sortedArray 43 - : sortedArray.length > 0 44 - ? [sortedArray[0]] 45 - : []; 34 + const slicedArray = 35 + open || !collabsible 36 + ? sortedArray 37 + : sortedArray.length > 0 38 + ? [sortedArray[0]] 39 + : []; 46 40 // 47 41 48 42 return ( ··· 66 60 <div className="bg-muted absolute inset-x-0 mx-auto h-full w-[2px]" /> 67 61 ) : null} 68 62 </div> 69 - <div className="mt-1 grid flex-1 gap-3"> 63 + <div className="mt-1 grid flex-1"> 70 64 {editable ? ( 71 - <div className="absolute bottom-2 right-2 hidden gap-2 group-hover:flex group-active:flex"> 65 + <div className="absolute right-2 top-2 hidden gap-2 group-hover:flex group-active:flex"> 72 66 <Button 73 67 size="icon" 74 68 variant="outline" 75 69 className="h-7 w-7 p-0" 76 70 onClick={() => { 77 71 router.push( 78 - `./status-reports/update/edit?id=${update.statusReportId}&statusUpdate=${update.id}`, 72 + `./${update.statusReportId}/update/edit?statusUpdate=${update.id}`, 79 73 ); 80 74 }} 81 75 > ··· 84 78 <DeleteStatusReportUpdateButtonIcon id={update.id} /> 85 79 </div> 86 80 ) : undefined} 87 - <div className="flex items-center justify-between gap-4"> 88 - <p className="text-sm font-semibold">{label}</p> 89 - <TooltipProvider> 90 - <Tooltip> 91 - <TooltipTrigger className="text-muted-foreground text-xs font-light"> 92 - {formatDistance(new Date(update.date), new Date(), { 93 - addSuffix: true, 94 - })} 95 - </TooltipTrigger> 96 - <TooltipContent> 97 - <p>{format(new Date(update.date), "LLL dd, y HH:mm")}</p> 98 - </TooltipContent> 99 - </Tooltip> 100 - </TooltipProvider> 81 + <div className="flex items-center gap-2"> 82 + <p className="text-sm font-medium">{label}</p> 83 + <p className="text-muted-foreground mt-px text-xs"> 84 + <code> 85 + {format(new Date(update.date), "LLL dd, y HH:mm")} 86 + </code> 87 + </p> 101 88 </div> 102 89 {/* <p className="max-w-3xl text-sm">{update.message}</p> */} 103 90 <EventMessage message={update.message} /> ··· 105 92 </div> 106 93 ); 107 94 })} 108 - 109 - {statusReportUpdates.length > 1 ? ( 95 + {collabsible && statusReportUpdates.length > 1 ? ( 110 96 <div className="text-center"> 111 97 <Button variant="ghost" onClick={toggle}> 112 98 {open ? "Close" : "More"}
+58
apps/web/src/components/status-update/summary.tsx
··· 1 + import { format, formatDistanceStrict } from "date-fns"; 2 + 3 + import type { 4 + Monitor, 5 + StatusReport, 6 + StatusReportUpdate, 7 + } from "@openstatus/db/src/schema"; 8 + import { Badge } from "@openstatus/ui"; 9 + 10 + import { StatusBadge } from "./status-badge"; 11 + 12 + export function Summary({ 13 + report, 14 + monitors, 15 + }: { 16 + report: StatusReport & { statusReportUpdates: StatusReportUpdate[] }; 17 + monitors: Pick<Monitor, "name">[]; 18 + }) { 19 + const sortedStatusReportUpdates = report.statusReportUpdates.sort( 20 + (a, b) => a.date.getTime() - b.date.getTime(), 21 + ); 22 + 23 + const firstUpdate = sortedStatusReportUpdates?.[0]; 24 + const lastUpdate = 25 + sortedStatusReportUpdates?.[sortedStatusReportUpdates.length - 1]; 26 + 27 + return ( 28 + <div className="grid grid-cols-5 gap-3 text-sm"> 29 + <p className="text-muted-foreground col-start-1">Started</p> 30 + <p className="col-span-4"> 31 + {firstUpdate ? ( 32 + <code>{format(new Date(firstUpdate.date), "LLL dd, y HH:mm")}</code> 33 + ) : null} 34 + </p> 35 + <p className="text-muted-foreground col-start-1">Status</p> 36 + <div className="col-span-4 flex items-center gap-2"> 37 + <StatusBadge status={report.status} /> 38 + {firstUpdate && lastUpdate && report.status === "resolved" ? ( 39 + <span className="text-muted-foreground text-xs"> 40 + after {formatDistanceStrict(firstUpdate.date, lastUpdate.date)} 41 + </span> 42 + ) : null} 43 + </div> 44 + <p className="text-muted-foreground col-start-1">Affected</p> 45 + <ul role="list" className="col-span-4 flex gap-2"> 46 + {monitors.length > 0 ? ( 47 + monitors.map(({ name }, i) => ( 48 + <li key={i} className="text-xs"> 49 + <Badge variant="outline">{name}</Badge> 50 + </li> 51 + )) 52 + ) : ( 53 + <li>-</li> 54 + )} 55 + </ul> 56 + </div> 57 + ); 58 + }
+6 -2
packages/api/src/router/statusReport.ts
··· 311 311 status: statusReportStatusSchema.default("investigating"), // TODO: remove! 312 312 monitorsToStatusReports: z 313 313 .array( 314 - z.object({ statusReportId: z.number(), monitorId: z.number() }), 314 + z.object({ 315 + statusReportId: z.number(), 316 + monitorId: z.number(), 317 + monitor: selectMonitorSchema, 318 + }), 315 319 ) 316 320 .default([]), 317 321 pagesToStatusReports: z ··· 327 331 eq(statusReport.workspaceId, opts.ctx.workspace.id), 328 332 ), 329 333 with: { 330 - monitorsToStatusReports: true, 334 + monitorsToStatusReports: { with: { monitor: true } }, 331 335 pagesToStatusReports: true, 332 336 statusReportUpdates: { 333 337 orderBy: (statusReportUpdate, { desc }) => [
+1 -1
packages/db/src/schema/shared.ts
··· 13 13 export const selectStatusReportPageSchema = z.array( 14 14 selectStatusReportSchema.extend({ 15 15 statusReportUpdates: z.array(selectStatusReportUpdateSchema).default([]), 16 - monitorsToStatusReport: z 16 + monitorsToStatusReports: z 17 17 .array(z.object({ monitorId: z.number(), statusReportId: z.number() })) 18 18 .default([]), 19 19 }),