Files
calendar/apps/web/modules/webhooks/components/WebhookListItem.tsx
T
devin-ai-integration[bot]GitHubpasqualevitielloDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>pasquale@cal.com <pasquale@cal.com>Peer RichelsenKeith Williams
9a08d2e46c refactor: migrate webhooks page to coss/ui components (#27714)
* chore: pull coss ui

* refactor: migrate webhooks page to coss/ui components

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor: migrate CreateNewWebhookButton to coss/ui Menu components

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor: adjust CreateNewWebhookButton variant and align based on context

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* feat(coss-ui): add list-item components and refactor WebhookListItem to use them

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(webhooks): match prototype – remove docs link, flatten list, show user in item

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* update css vars

* refactor(webhooks): migrate WebhookForm.tsx to coss-ui components

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): add FormProvider for useFormContext in child components

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* restore prev form

* swap form components

* mc

* use coss button everywhere

* fix ts errors

* refactor(webhooks): replace SettingsHeaderWithBackButton with coss-ui CardFrame in edit/new views

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(webhooks): split into two CardFrames with Card/CardPanel

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(webhooks): wrap WebhookTestDisclosure in Card/CardPanel

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(webhooks): migrate WebhookTestDisclosure to coss-ui CardFrame

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* mc

* mc

* refactor(webhooks): update skeletons to use coss-ui CardFrame pattern

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* mc

* refactor(webhooks): improve new webhook skeleton to match actual UI layout

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* imrpove form skeleton

* optimise card header

* refactor: improve base ui react-hook-form compatibility

* update string

* use coss ui alert dialog

* use coss toast

* feat(webhooks): add user filter to webhooks list

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): resolve lint warnings in filter component

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(webhooks): filter after new button, icon+badge only

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* improve switch

* remove unneeded toast

* refactor(webhooks): enhance WebhooksFilter component with anchor reference

* mc

* fix(webhooks): update Switch component to use checked prop instead of defaultChecked

* fix(webhooks): prevent Suspense boundary error on webhook edit save

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): add optimistic state for Switch toggle in WebhookListItem

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): disable switch during pending mutation to prevent rapid-toggle race

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): guard toggle with isPending check instead of disabled prop

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix toggle

* mc

* mc

* fix(webhooks): navigate before revalidation to prevent Suspense re-suspension

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* mc

* fix(webhooks): use render prop for Cancel button navigation

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): simplify Cancel button with explicit conditional rendering

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(webhooks): sort list by id to prevent resorting on toggle

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* mc

* mc

* show filter only if user is >1

* refactor(webhooks): move ListItem components to shared directory

* feat: reintroduce avatars

* remove unneeded translations

* remove unneeded useMemo

* add clickable +n badge

* mc

* mc

* add tooltips and docs link

* animated tooltips

* refactor(WebhookForm): replace hardcoded text with translation keys for better localization

* refactor(WebhookListItem): manage active state with useEffect and improve toggle error handling

* mc

* update toast position + re-pull coss ui components

* refactor: rename onEditWebhook to onEditWebhookAction to prevent warnings

* feat: enhance WebhookListItem with edit link and mobile support

* mc

* remove unneded component

* refactor: simplify webhook toggle logic in WebhookListItem component

* fix: update edit link logic in WebhookListItem and add editHref to webhooks-view

* wip

* feat: implement webhook version selection in WebhookForm

* update list item

* fix issue with delete button perrmission

* sort webhooks by ID in WebhooksList component

* mc

* refactor: enhance Webhook components with improved header and skeleton loading logic

* refactor: rename files for clarity

* display the edit button

* re-pull coss ui components

* fix ts error

* refactor: simplify permission checks in WebhookListItem component

* improve skeletons

* fix ts errors

* rename onEditWebhook

* fix: update SheetContent variant in ActiveUserBreakdown component

* fix e2e

* feat: add data-testid attribute to confirmation button in DeleteWebhookDialog

* remove unnecessary file

* refactor: allow passing the aria-label attribute to the button that removes a chip

* refactor: rename onEditWebhook to onEditWebhookAction to prevent warnings

* refactor: add webhook version select to event-type dialog and extract shared WebhookVersionCTA component

Co-Authored-By: pasquale@cal.com <pasqualevitiello@gmail.com>

* fix: restore toast notification on webhook toggle in event-types tab

Co-Authored-By: pasquale@cal.com <pasqualevitiello@gmail.com>

* implement the coss ui dialog

* refactor(webhooks): use shared WebhookListItem in event type tab and coss-ui Dialog

* remove unneeded lastItem prop

* re-pull coss ui components

* Merge remote-tracking branch 'origin/main' into style/improve-webhooks

Co-Authored-By: unknown <>

* refactor: extract AppHeader component and move page title outside CardFrame

Co-Authored-By: unknown <>

* refactor: show user header for every webhook group and remove CardFrame wrapper

Co-Authored-By: unknown <>

* fix: wrap each user webhook group in CardFrame with CardFrameHeader

Co-Authored-By: unknown <>

* fix: add dashed border to Empty state matching coss prototype

Co-Authored-By: unknown <>

* mc

* fix: only show New button in header when webhooks exist

Co-Authored-By: unknown <>

* refactor: move page title/description/back button out of CardFrame on new/edit webhook pages

Co-Authored-By: unknown <>

* refactor: render webhook form header outside flex-col gap wrapper

Co-Authored-By: unknown <>

* refactor: move header outside div wrapper in webhook form skeleton

Co-Authored-By: unknown <>

* mc

* skeleton improvements

* refactor: move AppHeader component to @coss/ui/shared/app-header

Co-Authored-By: unknown <>

* fix the webhook dialog on event-types settings

* make the webhook clickable for event types

* remove hardcoded string

---------

Co-authored-by: pasqualevitiello <pasqualevitiello@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: pasquale@cal.com <pasquale@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2026-03-04 12:56:15 +00:00

361 lines
14 KiB
TypeScript

"use client";
import { getWebhookVersionLabel } from "@calcom/features/webhooks/lib/constants";
import type { Webhook } from "@calcom/features/webhooks/lib/dto/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { Badge } from "@coss/ui/components/badge";
import { Button } from "@coss/ui/components/button";
import {
Menu,
MenuCheckboxItem,
MenuGroup,
MenuItem,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@coss/ui/components/menu";
import { Switch } from "@coss/ui/components/switch";
import { toastManager } from "@coss/ui/components/toast";
import {
Tooltip,
TooltipPopup,
TooltipProvider,
TooltipTrigger,
} from "@coss/ui/components/tooltip";
import { useIsMobile } from "@coss/ui/hooks/use-mobile";
import {
ListItem,
ListItemActions,
ListItemBadges,
ListItemContent,
ListItemHeader,
ListItemTitle,
ListItemTitleLink,
} from "@coss/ui/shared/list-item";
import { EllipsisIcon, ExternalLinkIcon, PencilIcon, TrashIcon, WebhookIcon } from "@coss/ui/icons";
import Link from "next/link";
import { useEffect, useState } from "react";
import { DeleteWebhookDialog } from "./dialogs/DeleteWebhookDialog";
const MAX_BADGES_TWO_ROWS = 7;
export default function WebhookListItem(props: {
webhook: Webhook;
profile?: { name: string | null; image?: string; slug?: string | null };
canEditWebhook?: boolean;
editHref?: string;
onEditWebhookAction?: () => void;
permissions: {
canEditWebhook?: boolean;
canDeleteWebhook?: boolean;
};
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { webhook } = props;
const [active, setActive] = useState(webhook.active);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [badgesExpanded, setBadgesExpanded] = useState(false);
const isMobile = useIsMobile();
useEffect(() => {
setActive(webhook.active);
}, [webhook.active]);
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
async onSuccess() {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
revalidateWebhooksList();
toastManager.add({ title: t("webhook_removed_successfully"), type: "success" });
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
setDeleteDialogOpen(false);
},
onError() {
toastManager.add({ title: t("something_went_wrong"), type: "error" });
setDeleteDialogOpen(false);
},
});
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
revalidateWebhooksList();
toastManager.add({ title: t("webhook_updated_successfully"), type: "success" });
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
},
onError() {
toastManager.add({ title: t("something_went_wrong"), type: "error" });
},
});
return (
<ListItem data-testid="webhook-list-item" className="*:px-4">
<ListItemContent>
<ListItemHeader>
<div className="flex items-center gap-2">
{props.permissions.canEditWebhook && props.editHref ? (
<ListItemTitle data-testid="webhook-url" className="font-medium truncate">
<ListItemTitleLink href={props.editHref}>{webhook.subscriberUrl}</ListItemTitleLink>
</ListItemTitle>
) : props.permissions.canEditWebhook && props.onEditWebhookAction ? (
<ListItemTitle data-testid="webhook-url" className="font-medium truncate">
<a
className="before:absolute before:inset-0 cursor-pointer"
data-slot="list-item-title-link"
href="#"
onClick={(e) => {
e.preventDefault();
props.onEditWebhookAction?.();
}}>
{webhook.subscriberUrl}
</a>
</ListItemTitle>
) : (
<ListItemTitle data-testid="webhook-url" className="font-medium truncate">
{webhook.subscriberUrl}
</ListItemTitle>
)}
<div className="flex items-center gap-2">
{!props.permissions.canEditWebhook && <Badge variant="warning">{t("readonly")}</Badge>}
<div className="flex items-center">
<TooltipProvider delay={0}>
<Tooltip>
<TooltipTrigger
className="after:absolute after:left-full after:h-full after:w-1"
render={<Badge variant="info" />}>
{getWebhookVersionLabel(webhook.version)}
</TooltipTrigger>
<TooltipPopup>{t("webhook_version")}</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<a
className="relative flex h-5 items-center justify-center px-2 sm:h-4.5"
href={`https://cal.com/docs/developing/guides/automation/webhooks#${webhook.version}`}
rel="noopener noreferrer"
target="_blank"
/>
}>
<span className="sr-only">{t("webhook_version_docs")}</span>
<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0 sm:size-3" />
</TooltipTrigger>
<TooltipPopup>
{t("webhook_version_docs", {
version: getWebhookVersionLabel(webhook.version),
})}
</TooltipPopup>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</ListItemHeader>
<ListItemBadges>
{webhook.eventTriggers.slice(0, badgesExpanded ? undefined : MAX_BADGES_TWO_ROWS).map((trigger) => (
<Badge key={trigger} variant="outline" className="pointer-events-none">
<WebhookIcon />
{t(`${trigger.toLowerCase()}`)}
</Badge>
))}
{!badgesExpanded && webhook.eventTriggers.length > MAX_BADGES_TWO_ROWS && (
<Badge
variant="outline"
render={<button type="button" />}
onClick={() => setBadgesExpanded(true)}>
+{webhook.eventTriggers.length - MAX_BADGES_TWO_ROWS} {t("more")}
</Badge>
)}
</ListItemBadges>
</ListItemContent>
<ListItemActions>
{!isMobile && (
<TooltipProvider delay={0}>
<div className="flex items-center gap-4">
{props.permissions.canEditWebhook ? (
<Tooltip>
<TooltipTrigger
render={
<Switch
checked={active}
data-testid="webhook-switch"
disabled={toggleWebhook.isPending}
onCheckedChange={(checked) => {
setActive(checked);
toggleWebhook.mutate({
id: webhook.id,
active: checked,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
});
}}
/>
}
/>
<TooltipPopup sideOffset={11}>
{active ? t("disable_webhook") : t("enable_webhook")}
</TooltipPopup>
</Tooltip>
) : (
<Switch checked={active} disabled />
)}
<div className="flex items-center gap-2">
{props.permissions.canEditWebhook ? (
props.editHref ? (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t("edit")}
data-testid="webhook-edit-button"
render={<Link href={props.editHref} />}
size="icon"
variant="outline"
>
<PencilIcon aria-hidden="true" />
</Button>
}
/>
<TooltipPopup>{t("edit")}</TooltipPopup>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t("edit")}
data-testid="webhook-edit-button"
size="icon"
variant="outline"
onClick={props.onEditWebhookAction}
>
<PencilIcon aria-hidden="true" />
</Button>
}
/>
<TooltipPopup>{t("edit")}</TooltipPopup>
</Tooltip>
)
) : (
<Button aria-label={t("edit")} disabled size="icon" variant="outline">
<PencilIcon aria-hidden="true" />
</Button>
)}
{props.permissions.canDeleteWebhook ? (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t("delete")}
data-testid="delete-webhook"
size="icon"
variant="destructive-outline"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteWebhook.isPending}
>
<TrashIcon aria-hidden="true" />
</Button>
}
/>
<TooltipPopup>{t("delete")}</TooltipPopup>
</Tooltip>
) : (
<Button aria-label={t("delete")} disabled size="icon" variant="destructive-outline">
<TrashIcon aria-hidden="true" />
</Button>
)}
</div>
</div>
</TooltipProvider>
)}
{isMobile && (
<Menu>
<MenuTrigger render={<Button aria-label={t("options")} size="icon" variant="outline" />}>
<EllipsisIcon />
</MenuTrigger>
<MenuPopup align="end">
{props.permissions.canEditWebhook ? (
<MenuCheckboxItem
checked={active}
disabled={toggleWebhook.isPending}
onCheckedChange={(checked) => {
setActive(checked);
toggleWebhook.mutate({
id: webhook.id,
active: checked,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
});
}}
variant="switch"
>
{t("enable_webhook")}
</MenuCheckboxItem>
) : (
<MenuCheckboxItem defaultChecked={webhook.active} disabled variant="switch">
{t("enable_webhook")}
</MenuCheckboxItem>
)}
<MenuSeparator />
<MenuGroup>
{props.permissions.canEditWebhook ? (
props.editHref ? (
<MenuItem render={<Link href={props.editHref} />}>
<PencilIcon />
{t("edit")}
</MenuItem>
) : (
<MenuItem onClick={props.onEditWebhookAction}>
<PencilIcon />
{t("edit")}
</MenuItem>
)
) : (
<MenuItem disabled>
<PencilIcon />
{t("edit")}
</MenuItem>
)}
{props.permissions.canDeleteWebhook ? (
<MenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteWebhook.isPending}
>
<TrashIcon />
{t("delete")}
</MenuItem>
) : (
<MenuItem variant="destructive" disabled>
<TrashIcon />
{t("delete")}
</MenuItem>
)}
</MenuGroup>
</MenuPopup>
</Menu>
)}
</ListItemActions>
<DeleteWebhookDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
isPending={deleteWebhook.isPending}
onConfirm={() => {
deleteWebhook.mutate({
id: webhook.id,
eventTypeId: webhook.eventTypeId || undefined,
teamId: webhook.teamId || undefined,
});
}}
/>
</ListItem>
);
}