Skip to content

Commit dcb412f

Browse files
committed
Add webhooks page to project dashboard (#7172)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces a new `CreateWebhookModal` component and enhances the `Webhooks` functionality in the dashboard. It implements a `WebhooksTable` for displaying webhooks and their details, along with error handling and user notifications for actions like deleting webhooks. ### Detailed summary - Added `CreateWebhookModal` component for creating new webhooks. - Updated `ProjectSidebarLayout` to include a link to webhooks with a new badge. - Changed `WebhookResponse` and `WebhookFilters` interfaces to `export`. - Modified `WebhooksPage` to handle project and webhook loading with error management. - Introduced `WebhooksTable` for displaying webhooks with actions. - Implemented `RelativeTime` component to show formatted timestamps. - Added deletion functionality for webhooks with user notifications. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a "Webhooks" section in the project sidebar, highlighted with a "New" badge. - Added a dedicated Webhooks page displaying a list of webhooks in a sortable table with options to copy URLs and delete entries. - Included a "New Webhook" button and modal for future webhook creation. - Implemented a component to display relative creation times for webhooks. - **Bug Fixes** - Improved error handling and user feedback for webhook-related actions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4705a59 commit dcb412f

File tree

6 files changed

+334
-9
lines changed

6 files changed

+334
-9
lines changed

apps/dashboard/src/@/api/insight/webhooks.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
55
import { THIRDWEB_INSIGHT_API_DOMAIN } from "constants/urls";
66

7-
interface WebhookResponse {
7+
export interface WebhookResponse {
88
id: string;
99
name: string;
1010
team_id: string;
@@ -19,7 +19,7 @@ interface WebhookResponse {
1919
updated_at: string | null;
2020
}
2121

22-
interface WebhookFilters {
22+
export interface WebhookFilters {
2323
"v1.events"?: {
2424
chain_ids?: string[];
2525
addresses?: string[];
@@ -130,8 +130,8 @@ export async function getWebhooks(
130130
};
131131
}
132132
}
133-
// biome-ignore lint/correctness/noUnusedVariables: will be used in the next PR
134-
async function deleteWebhook(
133+
134+
export async function deleteWebhook(
135135
webhookId: string,
136136
clientId: string,
137137
): Promise<WebhookSingleResponse> {

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
33
import { Badge } from "@/components/ui/badge";
44
import {
5+
BellIcon,
56
BookTextIcon,
67
BoxIcon,
78
CoinsIcon,
@@ -94,6 +95,16 @@ export function ProjectSidebarLayout(props: {
9495
icon: NebulaIcon,
9596
tracking: tracking("nebula"),
9697
},
98+
{
99+
href: `${layoutPath}/webhooks`,
100+
label: (
101+
<span className="flex items-center gap-2">
102+
Webhooks <Badge>New</Badge>
103+
</span>
104+
),
105+
icon: BellIcon,
106+
tracking: tracking("webhooks"),
107+
},
97108
]}
98109
footerSidebarLinks={[
99110
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Button } from "@/components/ui/button";
2+
// Implementation is going to be added in the next PR
3+
export function CreateWebhookModal() {
4+
return <Button>New Webhook</Button>;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
import { cn } from "@/lib/utils";
3+
import { formatDistanceToNowStrict } from "date-fns";
4+
import { useEffect, useState } from "react";
5+
6+
export function RelativeTime({
7+
date,
8+
className,
9+
}: { date: string; className?: string }) {
10+
const [content, setContent] = useState(() => {
11+
const parsedDate = new Date(date);
12+
if (Number.isNaN(parsedDate.getTime())) return "-";
13+
try {
14+
return formatDistanceToNowStrict(parsedDate, { addSuffix: true });
15+
} catch {
16+
return "-";
17+
}
18+
});
19+
20+
// eslint-disable-next-line
21+
useEffect(() => {
22+
const updateContent = () => {
23+
const parsedDate = new Date(date);
24+
if (Number.isNaN(parsedDate.getTime())) {
25+
setContent("-");
26+
} else {
27+
try {
28+
setContent(
29+
formatDistanceToNowStrict(parsedDate, { addSuffix: true }),
30+
);
31+
} catch {
32+
setContent("-");
33+
}
34+
}
35+
};
36+
updateContent();
37+
const interval = setInterval(updateContent, 10000);
38+
return () => clearInterval(interval);
39+
}, [date]);
40+
41+
return <span className={cn(className)}>{content}</span>;
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"use client";
2+
3+
import {
4+
type WebhookFilters,
5+
type WebhookResponse,
6+
deleteWebhook,
7+
} from "@/api/insight/webhooks";
8+
import { CopyTextButton } from "@/components/ui/CopyTextButton";
9+
import { Spinner } from "@/components/ui/Spinner/Spinner";
10+
import { Badge } from "@/components/ui/badge";
11+
import { Button } from "@/components/ui/button";
12+
import { useDashboardRouter } from "@/lib/DashboardRouter";
13+
import type { ColumnDef } from "@tanstack/react-table";
14+
import { TWTable } from "components/shared/TWTable";
15+
import { format } from "date-fns";
16+
import { TrashIcon } from "lucide-react";
17+
import { useMemo, useState } from "react";
18+
import { toast } from "sonner";
19+
import { RelativeTime } from "./RelativeTime";
20+
21+
function getEventType(filters: WebhookFilters): string {
22+
if (filters["v1.events"]) return "Event";
23+
if (filters["v1.transactions"]) return "Transaction";
24+
return "Unknown";
25+
}
26+
27+
interface WebhooksTableProps {
28+
webhooks: WebhookResponse[];
29+
clientId: string;
30+
}
31+
32+
export function WebhooksTable({ webhooks, clientId }: WebhooksTableProps) {
33+
const [isDeleting, setIsDeleting] = useState<Record<string, boolean>>({});
34+
// const { testWebhookEndpoint, isTestingMap } = useTestWebhook(clientId);
35+
const router = useDashboardRouter();
36+
37+
const handleDeleteWebhook = async (webhookId: string) => {
38+
if (isDeleting[webhookId]) return;
39+
40+
try {
41+
setIsDeleting((prev) => ({ ...prev, [webhookId]: true }));
42+
await deleteWebhook(webhookId, clientId);
43+
toast.success("Webhook deleted successfully");
44+
router.refresh();
45+
} catch (error) {
46+
console.error("Error deleting webhook:", error);
47+
toast.error("Failed to delete webhook", {
48+
description:
49+
error instanceof Error
50+
? error.message
51+
: "An unexpected error occurred",
52+
});
53+
} finally {
54+
setIsDeleting((prev) => ({ ...prev, [webhookId]: false }));
55+
}
56+
};
57+
58+
const columns: ColumnDef<WebhookResponse>[] = [
59+
{
60+
accessorKey: "name",
61+
header: "Name",
62+
cell: ({ row }) => (
63+
<div className="flex items-center gap-2">
64+
<span className="max-w-40 truncate" title={row.original.name}>
65+
{row.original.name}
66+
</span>
67+
</div>
68+
),
69+
},
70+
{
71+
accessorKey: "filters",
72+
header: "Event Type",
73+
cell: ({ getValue }) => {
74+
const filters = getValue() as WebhookFilters;
75+
if (!filters) return <span>-</span>;
76+
const eventType = getEventType(filters);
77+
return <span>{eventType}</span>;
78+
},
79+
},
80+
{
81+
accessorKey: "webhook_url",
82+
header: "Webhook URL",
83+
cell: ({ getValue }) => {
84+
const url = getValue() as string;
85+
return (
86+
<div className="flex items-center gap-2">
87+
<span className="max-w-60 truncate">{url}</span>
88+
<CopyTextButton
89+
textToCopy={url}
90+
textToShow=""
91+
tooltip="Copy URL"
92+
variant="ghost"
93+
copyIconPosition="right"
94+
className="flex h-6 w-6 items-center justify-center"
95+
iconClassName="h-3 w-3"
96+
/>
97+
</div>
98+
);
99+
},
100+
},
101+
{
102+
accessorKey: "created_at",
103+
header: "Created",
104+
cell: ({ getValue }) => {
105+
const date = getValue() as string;
106+
return (
107+
<div className="flex flex-col">
108+
<RelativeTime date={date} />
109+
<span className="text-muted-foreground text-xs">
110+
{format(new Date(date), "MMM d, yyyy")}
111+
</span>
112+
</div>
113+
);
114+
},
115+
},
116+
{
117+
id: "status",
118+
accessorKey: "suspended_at",
119+
header: "Status",
120+
cell: ({ row }) => {
121+
const webhook = row.original;
122+
const isSuspended = Boolean(webhook.suspended_at);
123+
return (
124+
<Badge variant={isSuspended ? "destructive" : "default"}>
125+
{isSuspended ? "Suspended" : "Active"}
126+
</Badge>
127+
);
128+
},
129+
},
130+
{
131+
id: "actions",
132+
header: "Actions",
133+
cell: ({ row }) => {
134+
const webhook = row.original;
135+
136+
return (
137+
<div className="flex justify-end gap-2">
138+
<Button
139+
size="icon"
140+
variant="outline"
141+
className="h-8 w-8 text-red-500 hover:border-red-700 hover:text-red-700"
142+
onClick={() => handleDeleteWebhook(webhook.id)}
143+
disabled={isDeleting[webhook.id]}
144+
aria-label={`Delete webhook ${webhook.name}`}
145+
>
146+
{isDeleting[webhook.id] ? (
147+
<Spinner className="h-4 w-4" />
148+
) : (
149+
<TrashIcon className="h-4 w-4" />
150+
)}
151+
</Button>
152+
</div>
153+
);
154+
},
155+
},
156+
];
157+
158+
const sortedWebhooks = useMemo(() => {
159+
return [...webhooks].sort((a, b) => {
160+
const dateA = new Date(a.created_at);
161+
const dateB = new Date(b.created_at);
162+
163+
// Handle invalid dates by treating them as epoch (0)
164+
const timeA = Number.isNaN(dateA.getTime()) ? 0 : dateA.getTime();
165+
const timeB = Number.isNaN(dateB.getTime()) ? 0 : dateB.getTime();
166+
167+
return timeB - timeA;
168+
});
169+
}, [webhooks]);
170+
171+
return (
172+
<div className="w-full">
173+
<div className="mb-4 flex items-center justify-end">
174+
<Button type="button">New Webhook</Button>
175+
</div>
176+
<TWTable
177+
data={sortedWebhooks}
178+
columns={columns}
179+
isPending={false}
180+
isFetched={true}
181+
title="Webhooks"
182+
tableContainerClassName="mt-4"
183+
/>
184+
</div>
185+
);
186+
}
Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,89 @@
1-
// This is a temporary page to supress linting errors and will be implemented up in the stack
2-
import { getWebhooks } from "@/api/insight/webhooks";
1+
import { type WebhookResponse, getWebhooks } from "@/api/insight/webhooks";
2+
import { getProject } from "@/api/projects";
3+
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
4+
import { notFound } from "next/navigation";
5+
import { CreateWebhookModal } from "./components/CreateWebhookModal";
6+
import { WebhooksTable } from "./components/WebhooksTable";
37

4-
export default async function WebhooksPage() {
5-
const { data: webhooks, error } = await getWebhooks("123");
8+
export default async function WebhooksPage({
9+
params,
10+
}: { params: Promise<{ team_slug: string; project_slug: string }> }) {
11+
let webhooks: WebhookResponse[] = [];
12+
let clientId = "";
13+
let errorMessage = "";
614

7-
return <div>{JSON.stringify({ webhooks, error })}</div>;
15+
try {
16+
// Await params before accessing properties
17+
const resolvedParams = await params;
18+
const team_slug = resolvedParams.team_slug;
19+
const project_slug = resolvedParams.project_slug;
20+
21+
const project = await getProject(team_slug, project_slug);
22+
23+
if (!project) {
24+
notFound();
25+
}
26+
27+
clientId = project.publishableKey;
28+
29+
const webhooksRes = await getWebhooks(clientId);
30+
if (webhooksRes.error) {
31+
errorMessage = webhooksRes.error;
32+
} else if (webhooksRes.data) {
33+
webhooks = webhooksRes.data;
34+
}
35+
} catch (error) {
36+
errorMessage = "Failed to load webhooks. Please try again later.";
37+
console.error("Error loading project or webhooks", error);
38+
}
39+
40+
return (
41+
<div className="flex grow flex-col">
42+
<div className="border-b py-10">
43+
<div className="container max-w-7xl">
44+
<h1 className="mb-1 font-semibold text-3xl tracking-tight">
45+
Webhooks
46+
</h1>
47+
<p className="text-muted-foreground text-sm">
48+
Create and manage webhooks to get notified about blockchain events,
49+
transactions and more.{" "}
50+
<TrackedUnderlineLink
51+
category="webhooks"
52+
label="learn-more"
53+
target="_blank"
54+
href="https://portal.thirdweb.com/insight/webhooks"
55+
>
56+
Learn more about webhooks.
57+
</TrackedUnderlineLink>
58+
</p>
59+
</div>
60+
</div>
61+
<div className="h-6" />
62+
<div className="container max-w-7xl">
63+
{errorMessage ? (
64+
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-destructive bg-destructive/10 p-12 text-center">
65+
<div>
66+
<h3 className="mb-1 font-medium text-destructive text-lg">
67+
Unable to load webhooks
68+
</h3>
69+
<p className="text-muted-foreground">{errorMessage}</p>
70+
</div>
71+
</div>
72+
) : webhooks.length > 0 ? (
73+
<WebhooksTable webhooks={webhooks} clientId={clientId} />
74+
) : (
75+
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
76+
<div>
77+
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
78+
<p className="text-muted-foreground">
79+
Create a webhook to get started.
80+
</p>
81+
</div>
82+
<CreateWebhookModal />
83+
</div>
84+
)}
85+
</div>
86+
<div className="h-20" />
87+
</div>
88+
);
889
}

0 commit comments

Comments
 (0)