Openstatus www.openstatus.dev

feat: add data table to subscribers including accept and delete action (#684)

authored by

Maximilian Kaske and committed by
GitHub
3a5f7087 4e48a1e1

+369 -16
+2 -2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 1 + import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 2 3 3 export default function Loading() { 4 - return <SkeletonForm />; 4 + return <DataTableSkeleton />; 5 5 }
+8 -9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/page.tsx
··· 3 3 import { allPlans } from "@openstatus/plans"; 4 4 5 5 import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 6 + import { columns } from "@/components/data-table/page-subscriber/columns"; 7 + import { DataTable } from "@/components/data-table/page-subscriber/data-table"; 6 8 import { api } from "@/trpc/server"; 7 9 8 10 export default async function CustomDomainPage({ ··· 14 16 const page = await api.page.getPageById.query({ id }); 15 17 const workspace = await api.workspace.getWorkspace.query(); 16 18 17 - const isValid = allPlans[workspace.plan].limits["status-subscribers"]; 18 - 19 19 if (!page) return notFound(); 20 20 21 + const isValid = allPlans[workspace.plan].limits["status-subscribers"]; 21 22 if (!isValid) return <ProFeatureAlert feature={"Status page subscribers"} />; 22 23 23 - // TODO: add page-subscribers trpc endpoint first 24 - return ( 25 - <p className="text-muted-foreground text-sm"> 26 - Your users can subscribe to status report updates. A list with more 27 - detailed informations coming soon. 28 - </p> 29 - ); 24 + const data = await api.pageSubscriber.getPageSubscribersByPageId.query({ 25 + id, 26 + }); 27 + 28 + return <DataTable data={data} columns={columns} />; 30 29 }
+4 -5
apps/web/src/components/data-table/incident/columns.tsx
··· 5 5 6 6 import type { Incident } from "@openstatus/db/src/schema"; 7 7 8 + import { formatDateTime } from "@/lib/utils"; 8 9 import { DataTableRowActions } from "./data-table-row-actions"; 9 10 10 11 export const columns: ColumnDef<Incident>[] = [ ··· 29 30 header: "Started At", 30 31 cell: ({ row }) => { 31 32 const { startedAt } = row.original; 32 - const date = startedAt ? new Date(startedAt).toLocaleString() : "-"; 33 + const date = startedAt ? formatDateTime(startedAt) : "-"; 33 34 return ( 34 35 <div className="flex"> 35 36 <span className="text-muted-foreground max-w-[150px] truncate sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> ··· 44 45 header: "Acknowledged At", 45 46 cell: ({ row }) => { 46 47 const { acknowledgedAt } = row.original; 47 - const date = acknowledgedAt 48 - ? new Date(acknowledgedAt).toLocaleString() 49 - : "-"; 48 + const date = acknowledgedAt ? formatDateTime(acknowledgedAt) : "-"; 50 49 return ( 51 50 <div className="flex"> 52 51 <span className="text-muted-foreground max-w-[150px] truncate sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> ··· 61 60 header: "Resolved At", 62 61 cell: ({ row }) => { 63 62 const { resolvedAt } = row.original; 64 - const date = resolvedAt ? new Date(resolvedAt).toLocaleString() : "-"; 63 + const date = resolvedAt ? formatDateTime(resolvedAt) : "-"; 65 64 return ( 66 65 <div className="flex"> 67 66 <span className="text-muted-foreground max-w-[150px] truncate sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]">
+46
apps/web/src/components/data-table/page-subscriber/columns.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + 5 + import type { PageSubscriber } from "@openstatus/db/src/schema"; 6 + 7 + import { formatDateTime } from "@/lib/utils"; 8 + import { DataTableRowActions } from "./data-table-row-actions"; 9 + 10 + export const columns: ColumnDef<PageSubscriber>[] = [ 11 + { 12 + accessorKey: "email", 13 + header: "Email", 14 + cell: ({ row }) => { 15 + return <span>{row.getValue("email")}</span>; 16 + }, 17 + }, 18 + { 19 + accessorKey: "acceptedAt", 20 + header: "Accepted", 21 + cell: ({ row }) => { 22 + const { acceptedAt } = row.original; 23 + const date = acceptedAt ? formatDateTime(acceptedAt) : "-"; 24 + return <span className="text-muted-foreground">{date}</span>; 25 + }, 26 + }, 27 + { 28 + accessorKey: "createdAt", 29 + header: "Created", 30 + cell: ({ row }) => { 31 + const { createdAt } = row.original; 32 + const date = createdAt ? formatDateTime(createdAt) : "-"; 33 + return <span className="text-muted-foreground">{date}</span>; 34 + }, 35 + }, 36 + { 37 + id: "actions", 38 + cell: ({ row }) => { 39 + return ( 40 + <div className="text-right"> 41 + <DataTableRowActions row={row} /> 42 + </div> 43 + ); 44 + }, 45 + }, 46 + ];
+121
apps/web/src/components/data-table/page-subscriber/data-table-row-actions.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import type { Row } from "@tanstack/react-table"; 6 + import { MoreHorizontal } from "lucide-react"; 7 + 8 + import { selectPageSubscriberSchema } from "@openstatus/db/src/schema"; 9 + import { 10 + AlertDialog, 11 + AlertDialogAction, 12 + AlertDialogCancel, 13 + AlertDialogContent, 14 + AlertDialogDescription, 15 + AlertDialogFooter, 16 + AlertDialogHeader, 17 + AlertDialogTitle, 18 + AlertDialogTrigger, 19 + Button, 20 + DropdownMenu, 21 + DropdownMenuContent, 22 + DropdownMenuItem, 23 + DropdownMenuTrigger, 24 + } from "@openstatus/ui"; 25 + 26 + import { LoadingAnimation } from "@/components/loading-animation"; 27 + import { useToastAction } from "@/hooks/use-toast-action"; 28 + import { api } from "@/trpc/client"; 29 + 30 + interface DataTableRowActionsProps<TData> { 31 + row: Row<TData>; 32 + } 33 + 34 + export function DataTableRowActions<TData>({ 35 + row, 36 + }: DataTableRowActionsProps<TData>) { 37 + const subscriber = selectPageSubscriberSchema.parse(row.original); 38 + const router = useRouter(); 39 + const { toast } = useToastAction(); 40 + const [alertOpen, setAlertOpen] = React.useState(false); 41 + const [isPending, startTransition] = React.useTransition(); 42 + 43 + async function onDelete() { 44 + startTransition(async () => { 45 + try { 46 + if (!subscriber.id) return; 47 + await api.pageSubscriber.unsubscribeById.mutate({ 48 + id: subscriber.id, 49 + }); 50 + toast("deleted"); 51 + router.refresh(); 52 + setAlertOpen(false); 53 + } catch { 54 + toast("error"); 55 + } 56 + }); 57 + } 58 + 59 + async function onAccept() { 60 + startTransition(async () => { 61 + try { 62 + if (!subscriber.id) return; 63 + await api.pageSubscriber.acceptSubscriberById.mutate({ 64 + id: subscriber.id, 65 + }); 66 + toast("success"); 67 + router.refresh(); 68 + } catch { 69 + toast("error"); 70 + } 71 + }); 72 + } 73 + 74 + return ( 75 + <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 76 + <DropdownMenu> 77 + <DropdownMenuTrigger asChild> 78 + <Button 79 + variant="ghost" 80 + className="data-[state=open]:bg-accent h-8 w-8 p-0" 81 + > 82 + <span className="sr-only">Open menu</span> 83 + <MoreHorizontal className="h-4 w-4" /> 84 + </Button> 85 + </DropdownMenuTrigger> 86 + <DropdownMenuContent align="end"> 87 + {!subscriber.acceptedAt ? ( 88 + <DropdownMenuItem onClick={onAccept}>Accept</DropdownMenuItem> 89 + ) : null} 90 + <AlertDialogTrigger asChild> 91 + <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 92 + Delete 93 + </DropdownMenuItem> 94 + </AlertDialogTrigger> 95 + </DropdownMenuContent> 96 + </DropdownMenu> 97 + <AlertDialogContent> 98 + <AlertDialogHeader> 99 + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 100 + <AlertDialogDescription> 101 + The user will not be able to receive the latest status reports. But 102 + will be able to subscribe again. 103 + </AlertDialogDescription> 104 + </AlertDialogHeader> 105 + <AlertDialogFooter> 106 + <AlertDialogCancel>Cancel</AlertDialogCancel> 107 + <AlertDialogAction 108 + onClick={(e) => { 109 + e.preventDefault(); 110 + onDelete(); 111 + }} 112 + disabled={isPending} 113 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 114 + > 115 + {!isPending ? "Delete" : <LoadingAnimation />} 116 + </AlertDialogAction> 117 + </AlertDialogFooter> 118 + </AlertDialogContent> 119 + </AlertDialog> 120 + ); 121 + }
+81
apps/web/src/components/data-table/page-subscriber/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 + }
+4
apps/web/src/lib/utils.ts
··· 15 15 return format(date, "LLL dd, y"); 16 16 } 17 17 18 + export function formatDateTime(date: Date) { 19 + return format(date, "LLL dd, y HH:mm:ss"); 20 + } 21 + 18 22 export function notEmpty<TValue>( 19 23 value: TValue | null | undefined, 20 24 ): value is TValue {
+2
packages/api/src/edge.ts
··· 5 5 import { monitorRouter } from "./router/monitor"; 6 6 import { notificationRouter } from "./router/notification"; 7 7 import { pageRouter } from "./router/page"; 8 + import { pageSubscriberRouter } from "./router/pageSubscriber"; 8 9 import { statusReportRouter } from "./router/statusReport"; 9 10 import { userRouter } from "./router/user"; 10 11 import { workspaceRouter } from "./router/workspace"; ··· 22 23 notification: notificationRouter, 23 24 invitation: invitationRouter, 24 25 incident: incidentRouter, 26 + pageSubscriber: pageSubscriberRouter, 25 27 });
+101
packages/api/src/router/pageSubscriber.ts
··· 1 + import { TRPCError } from "@trpc/server"; 2 + import { z } from "zod"; 3 + 4 + import { and, eq } from "@openstatus/db"; 5 + import { page, pageSubscriber } from "@openstatus/db/src/schema"; 6 + 7 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 8 + 9 + export const pageSubscriberRouter = createTRPCRouter({ 10 + getPageSubscribersByPageId: protectedProcedure 11 + .input(z.object({ id: z.number() })) 12 + .query(async (opts) => { 13 + const _page = await opts.ctx.db.query.page.findFirst({ 14 + where: and( 15 + eq(page.workspaceId, opts.ctx.workspace.id), 16 + eq(page.id, opts.input.id), 17 + ), 18 + }); 19 + 20 + if (!_page) { 21 + throw new TRPCError({ 22 + code: "UNAUTHORIZED", 23 + message: "Unauthorized to get subscribers", 24 + }); 25 + } 26 + 27 + const data = await opts.ctx.db.query.pageSubscriber.findMany({ 28 + where: and(eq(pageSubscriber.pageId, _page.id)), 29 + }); 30 + return data; 31 + }), 32 + 33 + unsubscribeById: protectedProcedure 34 + .input(z.object({ id: z.number() })) 35 + .mutation(async (opts) => { 36 + const subscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ 37 + where: and(eq(pageSubscriber.id, opts.input.id)), 38 + }); 39 + 40 + if (!subscriber) { 41 + throw new TRPCError({ 42 + code: "NOT_FOUND", 43 + message: "Subscriber not found", 44 + }); 45 + } 46 + 47 + const _page = await opts.ctx.db.query.page.findFirst({ 48 + where: and( 49 + eq(page.id, subscriber!.pageId), 50 + eq(page.workspaceId, opts.ctx.workspace.id), 51 + ), 52 + }); 53 + 54 + if (!_page) { 55 + throw new TRPCError({ 56 + code: "UNAUTHORIZED", 57 + message: "Unauthorized to unsubscribe", 58 + }); 59 + } 60 + 61 + return await opts.ctx.db 62 + .delete(pageSubscriber) 63 + .where(eq(pageSubscriber.id, subscriber.id)); 64 + }), 65 + 66 + acceptSubscriberById: protectedProcedure 67 + .input(z.object({ id: z.number() })) 68 + .mutation(async (opts) => { 69 + const subscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ 70 + where: and(eq(pageSubscriber.id, opts.input.id)), 71 + }); 72 + 73 + if (!subscriber) { 74 + throw new TRPCError({ 75 + code: "NOT_FOUND", 76 + message: "Subscriber not found", 77 + }); 78 + } 79 + 80 + const _page = await opts.ctx.db.query.page.findFirst({ 81 + where: and( 82 + eq(page.id, subscriber!.pageId), 83 + eq(page.workspaceId, opts.ctx.workspace.id), 84 + ), 85 + }); 86 + 87 + if (!_page) { 88 + throw new TRPCError({ 89 + code: "UNAUTHORIZED", 90 + message: "Unauthorized to unsubscribe", 91 + }); 92 + } 93 + 94 + return await opts.ctx.db 95 + .update(pageSubscriber) 96 + .set({ 97 + acceptedAt: new Date(), 98 + }) 99 + .where(eq(pageSubscriber.id, subscriber.id)); 100 + }), 101 + });