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>
This commit is contained in:
devin-ai-integration[bot]
2026-03-04 12:56:15 +00:00
committed by GitHub
co-authored by pasquale@cal.com pasquale@cal.com unknown Peer Richelsen Keith Williams
parent 3f2e122295
commit 9a08d2e46c
29 changed files with 1735 additions and 1174 deletions
@@ -0,0 +1,5 @@
import { WebhookFormSkeleton } from "~/webhooks/views/webhook-form-skeleton";
export default function Loading() {
return <WebhookFormSkeleton titleKey="edit_webhook" />;
}
@@ -1,5 +1,5 @@
import { SkeletonLoader } from "~/webhooks/views/webhook-new-skeleton";
import { WebhookFormSkeleton } from "~/webhooks/views/webhook-form-skeleton";
export default function Loading() {
return <SkeletonLoader />;
return <WebhookFormSkeleton />;
}
+8 -5
View File
@@ -3,6 +3,7 @@
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { SessionProvider } from "next-auth/react";
import CacheProvider from "react-inlinesvg/provider";
import { ToastProvider } from "@coss/ui/components/toast";
import { WebPushProvider } from "@calcom/web/modules/notifications/components/WebPushContext";
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
@@ -24,11 +25,13 @@ export function Providers({ isEmbed, children, country }: ProvidersProps) {
<GeoProvider country={country}>
<SessionProvider>
<TrpcProvider>
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<WebPushProvider>{children}</WebPushProvider>
</CacheProvider>
<ToastProvider position="bottom-center">
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<WebPushProvider>{children}</WebPushProvider>
</CacheProvider>
</ToastProvider>
</TrpcProvider>
</SessionProvider>
</GeoProvider>
@@ -374,9 +374,8 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
onEditWebhookAction={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
@@ -1,25 +1,30 @@
import Link from "next/link";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import type { EventTypeSetupProps, FormValues } from "@calcom/features/eventtypes/lib/types";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { APP_NAME } from "@calcom/lib/constants";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent } from "@calcom/ui/components/dialog";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
import { WebhookForm } from "~/webhooks/components";
import EventTypeWebhookListItem from "~/webhooks/components/EventTypeWebhookListItem";
import { Card, CardPanel } from "@coss/ui/components/card";
import { Dialog, DialogPanel, DialogPopup } from "@coss/ui/components/dialog";
import {
AppHeader,
AppHeaderActions,
AppHeaderContent,
AppHeaderDescription,
} from "@coss/ui/shared/app-header";
import Link from "next/link";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { WebhookForm, WebhookListItem } from "~/webhooks/components";
import type { TWebhook, WebhookFormSubmitData } from "~/webhooks/components/WebhookForm";
import { WebhookVersionCTA } from "~/webhooks/components/WebhookVersionCTA";
import { WebhookFormHeader } from "~/webhooks/views/webhook-form-header";
export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const { t } = useLocale();
@@ -90,6 +95,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
eventTypeId: eventType.id,
time: values.time,
timeUnit: values.timeUnit,
version: values.version,
});
};
@@ -120,159 +126,182 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
<>
<div>
<div>
<>
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity={webhooksDisableProps.isLocked ? "neutral" : "info"}
className="mb-2"
title={
<ServerTrans
t={t}
i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admin"}`}
/>
}
actions={
<div className="flex h-full items-center">{webhooksDisableProps.LockedIcon}</div>
}
message={
<ServerTrans
t={t}
i18nKey={`webhooks_${lockedText}_${
isManagedEventType ? "for_members" : "by_team_admin"
}_description`}
/>
}
/>
)}
{webhooks.length ? (
<>
<div className="border-subtle mb-2 rounded-md border p-8">
<div className="flex justify-between">
<div>
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle wrap-break-word max-w-[280px] text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
</div>
{cannotEditWebhooks ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
) : (
<NewWebhookButton />
)}
</div>
<div className="border-subtle my-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<EventTypeWebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
readOnly={isChildrenManagedEventType && webhook.eventTypeId !== eventType.id}
/>
);
})}
</div>
<p className="text-default text-sm font-normal">
<ServerTrans
t={t}
i18nKey="edit_or_manage_webhooks"
components={[
<Link
key="edit_or_manage_webhooks"
className="cursor-pointer font-semibold underline"
href="/settings/developer/webhooks">
webhooks settings
</Link>,
]}
/>
</p>
</div>
</>
) : (
<EmptyScreen
Icon="webhook"
headline={t("create_your_first_webhook")}
description={t("first_event_type_webhook_description")}
buttonRaw={
cannotEditWebhooks ? (
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity={webhooksDisableProps.isLocked ? "neutral" : "info"}
className="mb-2"
title={
<ServerTrans
t={t}
i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admin"}`}
/>
}
actions={<div className="flex h-full items-center">{webhooksDisableProps.LockedIcon}</div>}
message={
<ServerTrans
t={t}
i18nKey={`webhooks_${lockedText}_${
isManagedEventType ? "for_members" : "by_team_admin"
}_description`}
/>
}
/>
)}
{webhooks.length ? (
<>
<AppHeader>
<AppHeaderContent title={t("webhooks")}>
<AppHeaderDescription>
{t("add_webhook_description", { appName: APP_NAME })}
</AppHeaderDescription>
</AppHeaderContent>
<AppHeaderActions>
{cannotEditWebhooks ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
) : (
<NewWebhookButton />
)
}
/>
)}
</>
)}
</AppHeaderActions>
</AppHeader>
<div className="mb-2 rounded-md border border-subtle p-8">
<Card className="mb-4">
<CardPanel className="p-0">
{webhooks.map((webhook, index) => {
const readOnly = isChildrenManagedEventType && webhook.eventTypeId !== eventType.id;
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
permissions={{
canEditWebhook: !readOnly,
canDeleteWebhook: !readOnly,
}}
onEditWebhookAction={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
</CardPanel>
</Card>
<p className="font-normal text-default text-sm">
<ServerTrans
t={t}
i18nKey="edit_or_manage_webhooks"
components={[
<Link
key="edit_or_manage_webhooks"
className="cursor-pointer font-semibold underline"
href="/settings/developer/webhooks"
/>,
]}
/>
</p>
</div>
</>
) : (
<EmptyScreen
Icon="webhook"
headline={t("create_your_first_webhook")}
description={t("first_event_type_webhook_description")}
buttonRaw={
cannotEditWebhooks ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
) : (
<NewWebhookButton />
)
}
/>
)}
</div>
</div>
{/* New webhook dialog */}
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
<DialogContent
enableOverflow
title={t("create_webhook")}
description={t("create_webhook_team_event_type")}>
<WebhookForm
noRoutingFormTriggers={true}
onSubmit={onCreateWebhook}
onCancel={() => setCreateModalOpen(false)}
apps={installedApps?.items.map((app) => app.slug)}
/>
</DialogContent>
<Dialog open={createModalOpen} onOpenChange={setCreateModalOpen}>
<DialogPopup showCloseButton={false} className="max-w-3xl">
<DialogPanel>
<WebhookForm
noRoutingFormTriggers={true}
onSubmit={onCreateWebhook}
onCancel={() => setCreateModalOpen(false)}
apps={installedApps?.items.map((app) => app.slug)}
headerWrapper={(formMethods, children) => (
<>
<WebhookFormHeader
titleKey="create_webhook"
showBackButton={false}
CTA={<WebhookVersionCTA formMethods={formMethods} />}
/>
{children}
</>
)}
/>
</DialogPanel>
</DialogPopup>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent enableOverflow title={t("edit_webhook")}>
<WebhookForm
noRoutingFormTriggers={true}
webhook={webhookToEdit}
apps={installedApps?.items.map((app) => app.slug)}
onCancel={() => setEditModalOpen(false)}
onSubmit={(values: WebhookFormSubmitData) => {
if (
subscriberUrlReserved({
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
<DialogPopup showCloseButton={false} className="max-w-3xl">
<DialogPanel>
<WebhookForm
noRoutingFormTriggers={true}
webhook={webhookToEdit}
apps={installedApps?.items.map((app) => app.slug)}
onCancel={() => setEditModalOpen(false)}
headerWrapper={(formMethods, children) => (
<>
<WebhookFormHeader
titleKey="edit_webhook"
showBackButton={false}
CTA={<WebhookVersionCTA formMethods={formMethods} />}
/>
{children}
</>
)}
onSubmit={(values: WebhookFormSubmitData) => {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: webhookToEdit?.id,
webhooks,
eventTypeId: eventType.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (values.changeSecret) {
values.secret = values.newSecret.length ? values.newSecret : null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
editWebhookMutation.mutate({
id: webhookToEdit?.id || "",
subscriberUrl: values.subscriberUrl,
id: webhookToEdit?.id,
webhooks,
eventTypeId: eventType.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (values.changeSecret) {
values.secret = values.newSecret.length ? values.newSecret : null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
editWebhookMutation.mutate({
id: webhookToEdit?.id || "",
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
eventTypeId: webhookToEdit?.eventTypeId || undefined,
timeUnit: values.timeUnit,
time: values.time,
});
}}
/>
</DialogContent>
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
eventTypeId: webhookToEdit?.eventTypeId || undefined,
timeUnit: values.timeUnit,
time: values.time,
version: values.version,
});
}}
/>
</DialogPanel>
</DialogPopup>
</Dialog>
</>
)}
@@ -1,35 +1,95 @@
"use client";
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Avatar, AvatarFallback, AvatarImage } from "@coss/ui/components/avatar";
import { Button } from "@coss/ui/components/button";
import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuTrigger } from "@coss/ui/components/menu";
import { ChevronDownIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { CreateButtonWithTeamsList } from "~/ee/teams/components/createButton/CreateButtonWithTeamsList";
function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export const CreateNewWebhookButton = () => {
export const CreateNewWebhookButton = ({ isEmptyState }: { isEmptyState?: boolean }) => {
const router = useRouter();
const { t } = useLocale();
const createFunction = (teamId?: number, platform?: boolean) => {
if (platform) {
router.push(`webhooks/new${platform ? `?platform=${platform}` : ""}`);
const query = trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery({
includeOrg: true,
withPermission: {
permission: "webhook.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
});
if (!query.data) return null;
const options = query.data
.filter((profile) => !profile.readOnly)
.map((profile) => ({
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
slug: profile.slug,
}));
const handleSelect = (option: { teamId?: number | null; platform?: boolean }) => {
if (option.platform) {
router.push(`webhooks/new?platform=${option.platform}`);
} else {
router.push(`webhooks/new${teamId ? `?teamId=${teamId}` : ""}`);
router.push(`webhooks/new${option.teamId ? `?teamId=${option.teamId}` : ""}`);
}
};
if (options.length <= 1) {
return (
<Button
data-testid="new_webhook"
onClick={() => handleSelect(options[0] || {})}
variant={isEmptyState ? "default" : "outline"}>
<PlusIcon aria-hidden="true" />
{t("new")}
</Button>
);
}
return (
<CreateButtonWithTeamsList
color="secondary"
subtitle={t("create_for").toUpperCase()}
createFunction={createFunction}
data-testid="new_webhook"
includeOrg={true}
withPermission={{
permission: "webhook.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}}
/>
<Menu>
<MenuTrigger
render={<Button data-testid="new_webhook" variant={isEmptyState ? "default" : "outline"} />}>
{t("new")}
<ChevronDownIcon aria-hidden="true" />
</MenuTrigger>
<MenuPopup align={isEmptyState ? undefined : "end"}>
<MenuGroup>
<MenuGroupLabel>{t("create_for")}</MenuGroupLabel>
{options.map((option, idx) => (
<MenuItem
key={option.label}
data-testid={`option${option.teamId ? "-team" : ""}-${idx}`}
onClick={() => handleSelect(option)}>
<span className="flex items-center gap-2">
<Avatar className="size-5">
{option.image ? <AvatarImage alt={option.label || ""} src={option.image} /> : null}
<AvatarFallback className="text-[.625rem]">
{getInitials(option.label || "")}
</AvatarFallback>
</Avatar>
<span className="truncate">{option.label}</span>
</span>
</MenuItem>
))}
</MenuGroup>
</MenuPopup>
</Menu>
);
};
@@ -1,178 +0,0 @@
"use client";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { Switch } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
import { DeleteWebhookDialog } from "./dialogs/DeleteWebhookDialog";
type WebhookProps = {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
eventTypeId: number | null;
teamId: number | null;
};
export default function EventTypeWebhookListItem(props: {
webhook: WebhookProps;
onEditWebhook: () => void;
lastItem: boolean;
readOnly?: boolean;
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { webhook } = props;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
async onSuccess() {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
showToast(t("webhook_removed_successfully"), "success");
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
setDeleteDialogOpen(false);
},
onError() {
showToast(t("something_went_wrong"), "error");
setDeleteDialogOpen(false);
},
});
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
async onSuccess(data) {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
showToast(t(data?.active ? "enabled" : "disabled"), "success");
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
},
});
return (
<div
className={classNames(
"flex w-full justify-between p-4",
props.lastItem ? "" : "border-subtle border-b"
)}>
<div className="w-full truncate">
<div className="flex">
<Tooltip content={webhook.subscriberUrl}>
<p className="text-emphasis max-w-[600px] truncate text-sm font-medium">
{webhook.subscriberUrl}
</p>
</Tooltip>
{!!props.readOnly && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
)}
</div>
<Tooltip content={t("triggers_when")}>
<div className="flex w-4/5 flex-wrap">
{webhook.eventTriggers.map((trigger) => (
<Badge
key={trigger}
className="mt-2.5 basis-1/5 ltr:mr-2 rtl:ml-2"
variant="gray"
startIcon="zap">
{t(`${trigger.toLowerCase()}`)}
</Badge>
))}
</div>
</Tooltip>
</div>
{!props.readOnly && (
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
data-testid="webhook-switch"
onCheckedChange={() =>
toggleWebhook.mutate({
id: webhook.id,
active: !webhook.active,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
})
}
/>
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>
<Button
className="hidden lg:flex"
color="destructive"
StartIcon="trash"
variant="icon"
data-testid="delete-webhook"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteWebhook.isPending}
/>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
StartIcon="trash"
color="destructive"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteWebhook.isPending}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
)}
<DeleteWebhookDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
isPending={deleteWebhook.isPending}
onConfirm={() => {
deleteWebhook.mutate({
id: webhook.id,
eventTypeId: webhook.eventTypeId || undefined,
teamId: webhook.teamId || undefined,
});
}}
/>
</div>
);
}
@@ -1,26 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/lib/integrationTemplate";
import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextArea } from "@calcom/ui/components/form";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Form } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Switch } from "@calcom/ui/components/form";
import { TimeTimeUnitInput } from "~/ee/workflows/components/TimeTimeUnitInput";
import { Button } from "@coss/ui/components/button";
import { Card, CardFrame, CardFrameFooter, CardPanel } from "@coss/ui/components/card";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@coss/ui/components/collapsible";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxPopup,
ComboboxValue,
} from "@coss/ui/components/combobox";
import { Field, FieldDescription, FieldError, FieldLabel } from "@coss/ui/components/field";
import { Group, GroupSeparator } from "@coss/ui/components/group";
import { Input } from "@coss/ui/components/input";
import { NumberField, NumberFieldGroup, NumberFieldInput } from "@coss/ui/components/number-field";
import { ScrollArea } from "@coss/ui/components/scroll-area";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@coss/ui/components/select";
import { Spinner } from "@coss/ui/components/spinner";
import { Switch } from "@coss/ui/components/switch";
import { Textarea } from "@coss/ui/components/textarea";
import { ExternalLinkIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import WebhookTestDisclosure from "./WebhookTestDisclosure";
export type TWebhook = RouterOutputs["viewer"]["webhook"]["list"][number];
@@ -301,13 +315,18 @@ const WebhookForm = (props: {
},
});
formMethods.register("version");
const triggers = formMethods.watch("eventTriggers") || [];
const subscriberUrl = formMethods.watch("subscriberUrl");
const time = formMethods.watch("time");
const timeUnit = formMethods.watch("timeUnit");
const TIME_UNITS = [TimeUnit.MINUTE, TimeUnit.HOUR, TimeUnit.DAY] as const;
const timeUnitItems = TIME_UNITS.map((unit) => ({
value: unit,
label: t(`${unit.toLowerCase()}_timeUnit`),
}));
const selectedTimeUnitItem = timeUnitItems.find((item) => item.value === timeUnit) ?? timeUnitItems[2];
const isCreating = !props?.webhook?.id;
const needsTime = triggers.some(
(t) =>
@@ -318,10 +337,6 @@ const WebhookForm = (props: {
const hasUrl = !!subscriberUrl;
const showTimeSection = needsTime;
const [useCustomTemplate, setUseCustomTemplate] = useState(
props?.webhook?.payloadTemplate !== undefined && props?.webhook?.payloadTemplate !== null
);
function insertVariableIntoTemplate(current: string, name: string, value: string): string {
try {
const parsed = JSON.parse(current || "{}");
@@ -343,6 +358,9 @@ const WebhookForm = (props: {
}
}
const [customPayloadOpen, setCustomPayloadOpen] = useState(
props?.webhook?.payloadTemplate !== undefined && props?.webhook?.payloadTemplate !== null
);
const [showVariables, setShowVariables] = useState(false);
const [newSecret, setNewSecret] = useState("");
const [changeSecret, setChangeSecret] = useState<boolean>(false);
@@ -365,274 +383,401 @@ const WebhookForm = (props: {
}
}, [changeSecret, formMethods]);
const formContent = (
const formFields = (
<div className="flex flex-col gap-6">
<Controller
name="subscriberUrl"
control={formMethods.control}
rules={{
required: t("subscriber_url_required", "Subscriber URL is required"),
}}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<FieldLabel>{t("subscriber_url")}</FieldLabel>
<Input
ref={ref}
placeholder="https://example.com/webhook"
type="url"
value={value}
required
onBlur={onBlur}
onValueChange={(urlValue) => {
onChange(urlValue);
if (urlValue && hasTemplateIntegration({ url: urlValue })) {
setCustomPayloadOpen(true);
formMethods.setValue("payloadTemplate", customTemplate({ url: urlValue }), {
shouldDirty: true,
});
}
}}
/>
<FieldError match={!!error}>{error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="active"
control={formMethods.control}
render={({ field: { name, value, onChange }, fieldState: { invalid, isTouched, isDirty } }) => (
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<FieldLabel>
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
{t("enable_webhook")}
</FieldLabel>
</Field>
)}
/>
<Controller
name="eventTriggers"
control={formMethods.control}
render={({ field: { name, onChange, value }, fieldState: { invalid, isTouched, isDirty } }) => {
const selectedItems = translatedTriggerOptions.filter((option) => value.includes(option.value));
return (
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<FieldLabel>{t("event_triggers")}</FieldLabel>
<Combobox
value={selectedItems}
onValueChange={(newValue) => {
onChange(newValue.map((item) => item.value));
const noShowWebhookTriggerExists = !!newValue.find(
(trigger) =>
trigger.value === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
trigger.value === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
);
if (noShowWebhookTriggerExists) {
formMethods.setValue("time", props.webhook?.time ?? 5, { shouldDirty: true });
formMethods.setValue("timeUnit", props.webhook?.timeUnit ?? TimeUnit.MINUTE, {
shouldDirty: true,
});
} else {
formMethods.setValue("time", undefined, { shouldDirty: true });
formMethods.setValue("timeUnit", undefined, { shouldDirty: true });
}
}}
items={translatedTriggerOptions}
multiple>
<ComboboxChips>
<ComboboxValue>
{(selectedValues: { value: WebhookTriggerEvents; label: string }[]) => (
<>
{selectedValues?.map((item) => (
<ComboboxChip
key={item.value}
removeProps={{ "aria-label": `${t("remove")} ${item.label}` }}>
{item.label}
</ComboboxChip>
))}
<ComboboxChipsInput
aria-label={t("event_triggers")}
placeholder={
selectedValues.length > 0
? undefined
: t("select_event_triggers", "Select event triggers…")
}
/>
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxPopup>
<ComboboxEmpty>{t("no_options_available")}</ComboboxEmpty>
<ComboboxList>
{(item: { value: WebhookTriggerEvents; label: string }) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
<ComboboxClear render={<Button size="xs" variant="outline" />}>
<TrashIcon />
{t("clear_all_triggers", "Clear all triggers")}
</ComboboxClear>
</Combobox>
</Field>
);
}}
/>
{showTimeSection && (
<Controller
name="time"
control={formMethods.control}
render={({
field: { value: timeValue, onChange: onTimeChange },
fieldState: { invalid: timeInvalid, isTouched: timeTouched, isDirty: timeDirty },
}) => (
<Controller
name="timeUnit"
control={formMethods.control}
render={({
field: { name, onChange: onTimeUnitChange },
fieldState: { invalid, isTouched, isDirty },
}) => (
<Field
name={name}
invalid={invalid || timeInvalid}
touched={isTouched || timeTouched}
dirty={isDirty || timeDirty}>
<FieldLabel>{t("how_long_after_user_no_show_minutes")}</FieldLabel>
<Group aria-label={t("how_long_after_user_no_show_minutes")} className="w-full">
<NumberField
aria-label="Duration"
className="gap-0"
value={timeValue ?? 5}
min={0}
onValueChange={(newValue) => onTimeChange(newValue ?? 5)}
render={<NumberFieldGroup />}>
<NumberFieldInput className="text-left" />
</NumberField>
<GroupSeparator />
<Select
value={selectedTimeUnitItem}
onValueChange={(newValue) => {
if (!newValue) return;
onTimeUnitChange(newValue.value);
}}
items={timeUnitItems}>
<SelectTrigger className="w-fit min-w-none">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{timeUnitItems.map((item) => (
<SelectItem key={item.value} value={item}>
{item.label}
</SelectItem>
))}
</SelectPopup>
</Select>
</Group>
</Field>
)}
/>
)}
/>
)}
<Controller
name="secret"
control={formMethods.control}
render={({ field: { name, value, onChange }, fieldState: { invalid, isTouched, isDirty } }) => (
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
{hasSecretKey ? (
<>
<FieldLabel>{t("secret")}</FieldLabel>
<div className="flex w-full gap-2">
<Input
autoComplete="off"
type={changeSecret ? "text" : "password"}
value={changeSecret ? newSecret : "••••••••••••"}
readOnly={!changeSecret}
disabled={!changeSecret}
onValueChange={
changeSecret
? (v) => {
setNewSecret(v);
onChange(v);
}
: undefined
}
placeholder={changeSecret ? t("leave_blank_to_remove_secret") : undefined}
className="flex-1"
/>
<Button
variant="outline"
onClick={() => {
if (changeSecret) {
setChangeSecret(false);
setNewSecret("");
} else {
const currentSecret = value || props?.webhook?.secret || "";
setNewSecret(currentSecret);
setChangeSecret(true);
}
}}>
{changeSecret ? t("cancel") : t("edit")}
</Button>
</div>
<FieldDescription>{t("forgotten_secret_description")}</FieldDescription>
</>
) : (
<>
<FieldLabel>{t("secret")}</FieldLabel>
<Input type="text" value={value ?? ""} onValueChange={onChange} />
</>
)}
</Field>
)}
/>
<Controller
name="payloadTemplate"
control={formMethods.control}
render={({ field: { name, value, onChange }, fieldState: { invalid, isTouched, isDirty } }) => {
// Flatten webhookVariables into a single array for payloadVariables
const payloadVariables = webhookVariables.flatMap(({ variables }) =>
variables.map(({ name: n, variable, description }) => ({
name: n,
variable: `{{${variable}}}`,
description,
}))
);
return (
<Collapsible onOpenChange={setCustomPayloadOpen} open={customPayloadOpen}>
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<FieldLabel>
<CollapsibleTrigger
nativeButton={false}
render={
<Switch
checked={customPayloadOpen}
onCheckedChange={(checked) => {
setCustomPayloadOpen(checked);
if (!checked) onChange(undefined);
}}
/>
}
/>
{t("custom_payload_template", "Custom Payload Template")}
</FieldLabel>
</Field>
<CollapsiblePanel className="-mx-1">
<div className="px-1 mt-4 flex flex-col items-start gap-2">
<Textarea
className="font-mono"
placeholder={"{\n \n}"}
rows={4}
value={value || ""}
onChange={(e) => onChange(e?.target?.value ?? "")}
/>
<Collapsible className="w-full" onOpenChange={setShowVariables} open={showVariables}>
<CollapsibleTrigger
render={
<Button size="sm" variant="outline">
{showVariables
? t("webhook_hide_variables", "Hide available variables")
: t("webhook_show_variable", "Show available variables")}
</Button>
}
/>
<CollapsiblePanel>
<ScrollArea
className="mt-4 h-64 rounded-lg border border-input"
scrollbarGutter
scrollFade>
<div className="p-2">
{webhookVariables.map(({ category, variables }) => (
<div key={category}>
<p className="my-1 px-[calc(--spacing(2)+1px)] font-medium text-sm">
{category}
</p>
<ul>
{variables.map(({ name, variable, description }) => (
<li key={name}>
<Button
className="h-auto! w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left"
variant="ghost"
onClick={() => {
const currentValue = formMethods.getValues("payloadTemplate") || "{}";
const updatedValue = insertVariableIntoTemplate(
currentValue,
name,
variable
);
onChange(updatedValue);
}}>
<span className="font-mono text-xs">{variable}</span>
<span className="font-normal text-muted-foreground text-xs">
{description}
</span>
</Button>
</li>
))}
</ul>
</div>
))}
</div>
</ScrollArea>
</CollapsiblePanel>
</Collapsible>
</div>
</CollapsiblePanel>
</Collapsible>
);
}}
/>
</div>
);
const cancelButton = props.onCancel ? (
<Button type="button" variant="ghost" onClick={props.onCancel}>
{t("cancel")}
</Button>
) : (
<Button type="button" variant="ghost" render={<Link href="/settings/developer/webhooks" />}>
{t("cancel")}
</Button>
);
const submitButton = (
<Button
type="submit"
data-testid="create_webhook"
disabled={!canSubmit || formMethods.formState.isSubmitting}>
{formMethods.formState.isSubmitting && <Spinner className="absolute" />}
<span className={formMethods.formState.isSubmitting ? "invisible" : undefined}>
{props?.webhook?.id ? t("save") : t("create_webhook")}
</span>
</Button>
);
if (props.headerWrapper) {
const cardContent = (
<CardFrame>
<Card className="rounded-b-none!">
<CardPanel>{formFields}</CardPanel>
</Card>
<CardFrameFooter className="flex justify-end gap-2">
{cancelButton}
{submitButton}
</CardFrameFooter>
</CardFrame>
);
return (
<Form
form={formMethods}
handleSubmit={(values) => props.onSubmit({ ...values, changeSecret, newSecret })}>
{props.headerWrapper(
formMethods,
<div className="flex flex-col gap-4">
{cardContent}
<WebhookTestDisclosure />
</div>
)}
</Form>
);
}
return (
<Form
form={formMethods}
handleSubmit={(values) => props.onSubmit({ ...values, changeSecret, newSecret })}>
<div className="border-subtle border p-6">
<Controller
name="subscriberUrl"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<TextField
name="subscriberUrl"
label={t("subscriber_url")}
labelClassName="font-medium text-emphasis font-sm"
value={value}
required
type="url"
onChange={(e) => {
formMethods.setValue("subscriberUrl", e?.target.value, { shouldDirty: true });
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomTemplate(true);
formMethods.setValue("payloadTemplate", customTemplate({ url: e.target.value }), {
shouldDirty: true,
});
}
}}
/>
</>
)}
/>
<Controller
name="active"
control={formMethods.control}
render={({ field: { value } }) => (
<div className="font-sm text-emphasis mt-6 font-medium">
<Switch
label={t("enable_webhook")}
checked={value}
// defaultChecked={props?.webhook?.active ? props?.webhook?.active : true}
onCheckedChange={(value) => {
formMethods.setValue("active", value, { shouldDirty: true });
}}
/>
</div>
)}
/>
<Controller
name="eventTriggers"
control={formMethods.control}
render={({ field: { onChange, value } }) => {
const selectValue = translatedTriggerOptions.filter((option) => value.includes(option.value));
return (
<div className="mt-6">
<Label className="font-sm text-emphasis font-medium">
<>{t("event_triggers")}</>
</Label>
<Select
grow
options={translatedTriggerOptions}
isMulti
styles={{
indicatorsContainer: (base) =>
Object.assign({}, base, {
alignItems: "flex-start",
}),
}}
value={selectValue}
onChange={(event) => {
onChange(event.map((selection) => selection.value));
const noShowWebhookTriggerExists = !!event.find(
(trigger) =>
trigger.value === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
trigger.value === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
);
if (noShowWebhookTriggerExists) {
formMethods.setValue("time", props.webhook?.time ?? 5, { shouldDirty: true });
formMethods.setValue("timeUnit", props.webhook?.timeUnit ?? TimeUnit.MINUTE, {
shouldDirty: true,
});
} else {
formMethods.setValue("time", undefined, { shouldDirty: true });
formMethods.setValue("timeUnit", undefined, { shouldDirty: true });
}
}}
/>
</div>
);
}}
/>
{showTimeSection && (
<div className="mt-5">
<Label>{t("how_long_after_user_no_show_minutes")}</Label>
<TimeTimeUnitInput disabled={false} defaultTime={5} />
</div>
)}
<Controller
name="secret"
control={formMethods.control}
render={({ field: { value } }) => (
<div className="mt-6">
{!!hasSecretKey && !changeSecret && (
<>
<Label className="font-sm text-emphasis font-medium">Secret</Label>
<div className="bg-default stack-y-0 rounded-md border-0 border-neutral-200 sm:mx-0 md:border">
<div className="text-emphasis rounded-sm border-b p-2 text-sm">
{t("forgotten_secret_description")}
</div>
<div className="p-2">
<Button
color="secondary"
type="button"
onClick={() => {
setChangeSecret(true);
}}>
{t("change_secret")}
</Button>
</div>
</div>
</>
)}
{!!hasSecretKey && changeSecret && (
<>
<TextField
autoComplete="off"
label={t("secret")}
labelClassName="font-medium text-emphasis font-sm"
{...formMethods.register("secret")}
value={newSecret}
onChange={(event) => setNewSecret(event.currentTarget.value)}
type="text"
placeholder={t("leave_blank_to_remove_secret")}
/>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(false);
}}>
{t("cancel")}
</Button>
</>
)}
{!hasSecretKey && (
<TextField
name="secret"
label={t("secret")}
labelClassName="font-medium text-emphasis font-sm"
value={value ?? ""}
onChange={(e) => {
formMethods.setValue("secret", e?.target.value, { shouldDirty: true });
}}
/>
)}
</div>
)}
/>
<Controller
name="payloadTemplate"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="font-sm text-emphasis mt-6">
<>{t("payload_template")}</>
</Label>
<div className="mb-2">
<ToggleGroup
onValueChange={(val) => {
if (val === "default") {
setUseCustomTemplate(false);
formMethods.setValue("payloadTemplate", undefined, { shouldDirty: true });
} else {
setUseCustomTemplate(true);
}
}}
value={useCustomTemplate ? "custom" : "default"}
options={[
{ value: "default", label: t("default") },
{ value: "custom", label: t("custom") },
]}
isFullWidth={true}
/>
</div>
{useCustomTemplate && (
<div className="stack-y-3">
<TextArea
name="customPayloadTemplate"
rows={8}
value={value || ""}
placeholder={`{\n\n}`}
onChange={(e) =>
formMethods.setValue("payloadTemplate", e?.target.value, { shouldDirty: true })
}
/>
<Button type="button" color="secondary" onClick={() => setShowVariables(!showVariables)}>
{showVariables ? t("webhook_hide_variables") : t("webhook_show_variable")}
</Button>
{showVariables && (
<div className="border-muted max-h-80 overflow-y-auto rounded-md border p-3">
{webhookVariables.map(({ category, variables }) => (
<div key={category} className="mb-4">
<h4 className="mb-2 text-sm font-medium">{category}</h4>
<div className="stack-y-2">
{variables.map(({ name, variable, description }) => (
<div
key={name}
className="hover:bg-cal-muted cursor-pointer rounded p-2 text-sm transition-colors"
onClick={() => {
const currentValue = formMethods.getValues("payloadTemplate") || "{}";
const updatedValue = insertVariableIntoTemplate(
currentValue,
name,
variable
);
formMethods.setValue("payloadTemplate", updatedValue, {
shouldDirty: true,
});
}}>
<div className="text-emphasis font-mono">{variable}</div>
<div className="text-muted mt-1 text-xs">{description}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
/>
</div>
{formFields}
<SectionBottomActions align="end" className="gap-2">
<Button
type="button"
color="minimal"
onClick={props.onCancel}
{...(!props.onCancel ? { href: `${WEBAPP_URL}/settings/developer/webhooks` } : {})}>
{t("cancel")}
</Button>
<Button
type="submit"
data-testid="create_webhook"
disabled={!canSubmit}
loading={formMethods.formState.isSubmitting}>
{props?.webhook?.id ? t("save") : t("create_webhook")}
</Button>
{cancelButton}
{submitButton}
</SectionBottomActions>
<div className="mb-4 mt-6 rounded-md">
<WebhookTestDisclosure />
</div>
</Form>
);
// If headerWrapper is provided, wrap the form content with it
if (props.headerWrapper) {
return <>{props.headerWrapper(formMethods, formContent)}</>;
}
return formContent;
};
export default WebhookForm;
@@ -1,39 +1,53 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { getWebhookVersionDocsUrl, getWebhookVersionLabel } from "@calcom/features/webhooks/lib/constants";
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 classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { Switch } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { ExternalLinkIcon } from "@coss/ui/icons";
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 = 8; // Approximately 2 rows of badges
const MAX_BADGES_TWO_ROWS = 7;
export default function WebhookListItem(props: {
webhook: Webhook;
profile?: { name: string | null; image?: string; slug?: string | null };
canEditWebhook?: boolean;
onEditWebhook: () => void;
lastItem: boolean;
editHref?: string;
onEditWebhookAction?: () => void;
permissions: {
canEditWebhook?: boolean;
canDeleteWebhook?: boolean;
@@ -42,168 +56,293 @@ export default function WebhookListItem(props: {
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();
showToast(t("webhook_removed_successfully"), "success");
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() {
showToast(t("something_went_wrong"), "error");
toastManager.add({ title: t("something_went_wrong"), type: "error" });
setDeleteDialogOpen(false);
},
});
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
async onSuccess(data) {
async onSuccess() {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
revalidateWebhooksList();
showToast(t(data?.active ? "enabled" : "disabled"), "success");
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 (
<div
data-testid="webhook-list-item"
className={classNames(
"flex w-full justify-between p-4",
props.lastItem ? "" : "border-subtle border-b"
)}>
<div className="w-full truncate">
<div className="flex">
<Tooltip content={webhook.subscriberUrl}>
<p className="text-emphasis max-w-[600px] truncate text-sm font-medium">
{webhook.subscriberUrl}
</p>
</Tooltip>
{!props.permissions.canEditWebhook && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
<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>
)}
<Tooltip content={t("webhook_version")}>
<div className="flex items-center">
<Badge variant="blue" className="ml-2">
{getWebhookVersionLabel(webhook.version)}
</Badge>
</div>
</Tooltip>
<Tooltip content={t("webhook_version_docs", { version: getWebhookVersionLabel(webhook.version) })}>
<Link
href={getWebhookVersionDocsUrl(webhook.version)}
target="_blank"
className="text-subtle hover:text-emphasis ml-1 flex items-center">
<ExternalLinkIcon className="h-4 w-4" />
</Link>
</Tooltip>
</div>
<Tooltip content={t("triggers_when")}>
<div className="flex w-4/5 flex-wrap">
{webhook.eventTriggers.slice(0, MAX_BADGES_TWO_ROWS).map((trigger) => (
<Badge
key={trigger}
className="mt-2.5 basis-1/5 ltr:mr-2 rtl:ml-2"
variant="gray"
startIcon="zap">
{t(`${trigger.toLowerCase()}`)}
</Badge>
))}
{webhook.eventTriggers.length > MAX_BADGES_TWO_ROWS && (
<Tooltip
content={
<div className="flex flex-col gap-1 p-1">
{webhook.eventTriggers.slice(MAX_BADGES_TWO_ROWS).map((trigger) => (
<span key={trigger} className="text-xs">
{t(`${trigger.toLowerCase()}`)}
</span>
))}
</div>
}>
<Badge className="mt-2.5 cursor-help ltr:mr-2 rtl:ml-2" variant="gray">
+{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>
</Tooltip>
</div>
{(props.permissions.canEditWebhook || props.permissions.canDeleteWebhook) && (
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
data-testid="webhook-switch"
disabled={!props.permissions.canEditWebhook}
onCheckedChange={(checked) =>
toggleWebhook.mutate({
id: webhook.id,
active: checked,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
})
}
/>
</TooltipProvider>
)}
{props.permissions.canEditWebhook && (
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>
)}
{props.permissions.canDeleteWebhook && (
<Button
className="hidden lg:flex"
color="destructive"
StartIcon="trash"
variant="icon"
data-testid="delete-webhook"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteWebhook.isPending}
/>
)}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.permissions.canEditWebhook && (
<DropdownMenuItem>
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
{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")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{props.permissions.canDeleteWebhook && (
<DropdownMenuItem>
<DropdownItem
StartIcon="trash"
color="destructive"
</MenuItem>
)}
{props.permissions.canDeleteWebhook ? (
<MenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteWebhook.isPending}>
disabled={deleteWebhook.isPending}
>
<TrashIcon />
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
)}
</MenuItem>
) : (
<MenuItem variant="destructive" disabled>
<TrashIcon />
{t("delete")}
</MenuItem>
)}
</MenuGroup>
</MenuPopup>
</Menu>
)}
</ListItemActions>
<DeleteWebhookDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
@@ -216,6 +355,6 @@ export default function WebhookListItem(props: {
});
}}
/>
</div>
</ListItem>
);
}
@@ -1,19 +0,0 @@
import { SkeletonText } from "@calcom/ui/components/skeleton";
export default function WebhookListItemSkeleton() {
return (
<div className="flex w-full justify-between p-4">
<div>
<p className="text-emphasis text-sm font-medium">
<SkeletonText className="h-4 w-56" />
</p>
<div className="mt-2.5 w-max">
<SkeletonText className="h-5 w-28" />
</div>
</div>
<div className="flex items-center space-x-4">
<SkeletonText className="h-9 w-9" />
</div>
</div>
);
}
@@ -1,15 +0,0 @@
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import WebhookListItemSkeleton from "./WebhookListItemSkeleton";
export default function WebhookListSkeleton() {
return (
<SkeletonContainer>
<div className="border-subtle divide-subtle mb-8 mt-6 divide-y rounded-md border">
<WebhookListItemSkeleton />
<WebhookListItemSkeleton />
<WebhookListItemSkeleton />
</div>
</SkeletonContainer>
);
}
@@ -1,14 +1,17 @@
"use client";
import { useWatch } from "react-hook-form";
import { ZodError } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { ZTestTriggerInputSchema } from "@calcom/trpc/server/routers/viewer/webhook/testTrigger.schema";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import { toastManager } from "@coss/ui/components/toast";
import { Badge } from "@coss/ui/components/badge";
import { Button } from "@coss/ui/components/button";
import { Card, CardFrame, CardPanel } from "@coss/ui/components/card";
import { Label } from "@coss/ui/components/label";
import { ActivityIcon } from "lucide-react";
import { useWatch } from "react-hook-form";
import { ZodError } from "zod";
import { WebhookTestHeader } from "../views/webhook-test-header";
export default function WebhookTestDisclosure() {
const [subscriberUrl, webhookSecret]: [string, string] = useWatch({ name: ["subscriberUrl", "secret"] });
@@ -16,69 +19,65 @@ export default function WebhookTestDisclosure() {
const { t } = useLocale();
const mutation = trpc.viewer.webhook.testTrigger.useMutation({
onError(err) {
showToast(err.message, "error");
toastManager.add({ title: err.message, type: "error" });
},
});
return (
<>
<div className="border-subtle flex justify-between rounded-t-lg border p-6">
<div>
<p className="text-emphasis text-sm font-semibold leading-5">{t("webhook_test")}</p>
<p className="text-default text-sm">{t("test_webhook")}</p>
</div>
<Button
type="button"
color="secondary"
disabled={mutation.isPending || !subscriberUrl}
StartIcon="activity"
onClick={() => {
try {
ZTestTriggerInputSchema.parse({
url: subscriberUrl,
secret: webhookSecret,
type: "PING",
payloadTemplate,
});
mutation.mutate({ url: subscriberUrl, secret: webhookSecret, type: "PING", payloadTemplate });
} catch (error) {
//this catches invalid subscriberUrl before calling the mutation
if (error instanceof ZodError) {
const errorMessage = error.errors.map((e) => e.message).join(", ");
showToast(errorMessage, "error");
} else {
showToast(t("unexpected_error_try_again"), "error");
<CardFrame>
<WebhookTestHeader
actions={
<Button
type="button"
size="sm"
variant="outline"
disabled={mutation.isPending || !subscriberUrl}
onClick={() => {
try {
ZTestTriggerInputSchema.parse({
url: subscriberUrl,
secret: webhookSecret,
type: "PING",
payloadTemplate,
});
mutation.mutate({ url: subscriberUrl, secret: webhookSecret, type: "PING", payloadTemplate });
} catch (error) {
if (error instanceof ZodError) {
const errorMessage = error.errors.map((e) => e.message).join(", ");
toastManager.add({ title: errorMessage, type: "error" });
} else {
toastManager.add({ title: t("unexpected_error_try_again"), type: "error" });
}
}
}
}}>
{t("ping_test")}
</Button>
</div>
<div className="border-subtle stack-y-0 rounded-b-lg border border-t-0 px-6 py-8 sm:mx-0">
<div className="border-subtle flex justify-between rounded-t-lg border p-4">
<div className="flex items-center space-x-1">
<h3 className="text-emphasis self-center text-sm font-semibold leading-4">
{t("webhook_response")}
</h3>
{mutation.data && (
<Badge variant={mutation.data.ok ? "green" : "red"}>
{mutation.data.ok ? t("passed") : t("failed")}
</Badge>
)}
</div>
</div>
<div className="bg-cal-muted border-subtle rounded-b-lg border border-t-0 p-4 font-mono text-[13px] leading-4">
{!mutation.data && <p>{t("no_data_yet")}</p>}
{mutation.status === "success" && mutation.data && (
<div className="stack-y-2">
<div>
<span className="text-subtle">{t("status")}:</span>{" "}
<span className="text-emphasis">{mutation.data.status}</span>
</div>
}}>
<ActivityIcon />
{t("ping_test")}
</Button>
}
/>
<Card>
<CardPanel>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 min-h-5">
<Label render={<div />}>{t("webhook_response")}</Label>
{mutation.data && (
<Badge variant={mutation.data.ok ? "success" : "error"}>
{mutation.data.ok ? t("passed") : t("failed")}
</Badge>
)}
</div>
)}
</div>
</div>
</>
<div className="rounded-lg border p-4 font-mono text-sm">
{!mutation.data && <p>{t("no_data_yet")}</p>}
{mutation.status === "success" && mutation.data && (
<div>
<span className="text-muted-foreground">{t("status")}:</span>{" "}
<span>{mutation.data.status}</span>
</div>
)}
</div>
</div>
</CardPanel>
</Card>
</CardFrame>
);
}
@@ -0,0 +1,87 @@
"use client";
import {
getWebhookVersionDocsUrl,
getWebhookVersionLabel,
WEBHOOK_VERSION_OPTIONS,
} from "@calcom/features/webhooks/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@coss/ui/components/button";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@coss/ui/components/select";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@coss/ui/components/tooltip";
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import type { UseFormReturn } from "react-hook-form";
import type { WebhookFormValues } from "./WebhookForm";
const webhookVersionItems = WEBHOOK_VERSION_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}));
export function WebhookVersionCTA({ formMethods }: { formMethods: UseFormReturn<WebhookFormValues> }) {
const { t } = useLocale();
const version = formMethods.watch("version");
const selectedVersionItem =
webhookVersionItems.find((item) => item.value === version) ?? webhookVersionItems[0];
return (
<div className="flex items-center gap-1 self-center">
<TooltipProvider delay={0}>
<Tooltip>
<TooltipTrigger
render={
<div className="inline-flex">
<Select
aria-label={t("webhook_version")}
value={selectedVersionItem}
onValueChange={(newValue) => {
if (newValue) {
formMethods.setValue("version", newValue.value, { shouldDirty: true });
}
}}
items={webhookVersionItems}>
<SelectTrigger size="sm" className="min-w-none">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{webhookVersionItems.map((item) => (
<SelectItem key={item.value} value={item}>
{item.label}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
}
/>
<TooltipPopup>{t("webhook_version")}</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-sm"
variant="ghost"
render={
<Link
className="flex text-muted-foreground hover:text-foreground"
href={getWebhookVersionDocsUrl(version)}
target="_blank"
rel="noopener noreferrer"
/>
}>
<ExternalLinkIcon />
</Button>
}
/>
<TooltipPopup>
{t("webhook_version_docs", {
version: getWebhookVersionLabel(version),
})}
</TooltipPopup>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -1,6 +1,16 @@
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import {
AlertDialog,
AlertDialogClose,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogPopup,
AlertDialogTitle,
} from "@coss/ui/components/alert-dialog";
import { Button } from "@coss/ui/components/button";
import { Spinner } from "@coss/ui/components/spinner";
interface DeleteWebhookDialogProps {
open: boolean;
@@ -13,19 +23,29 @@ export function DeleteWebhookDialog({ open, onOpenChange, onConfirm, isPending }
const { t } = useLocale();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
loadingText={t("confirm_delete_webhook")}
isPending={isPending}
onConfirm={(e) => {
e.preventDefault();
onConfirm();
}}>
{t("delete_webhook_confirmation_message", { appName: APP_NAME })}
</ConfirmationDialogContent>
</Dialog>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_webhook")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_webhook_confirmation_message", { appName: APP_NAME })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="ghost" />}>{t("cancel")}</AlertDialogClose>
<Button
data-testid="dialog-confirmation"
variant="destructive"
disabled={isPending}
onClick={onConfirm}
>
{isPending && <Spinner className="absolute" />}
<span className={isPending ? "invisible" : undefined}>
{t("confirm_delete_webhook")}
</span>
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
);
}
@@ -1,6 +1,4 @@
export { default as CreateNewWebhookButton } from "./CreateNewWebhookButton";
export { default as WebhookForm } from "./WebhookForm";
export { default as WebhookListItem } from "./WebhookListItem";
export { default as WebhookListItemSkeleton } from "./WebhookListItemSkeleton";
export { default as WebhookListSkeleton } from "./WebhookListSkeleton";
export { default as WebhookTestDisclosure } from "./WebhookTestDisclosure";
export { default as CreateNewWebhookButton } from "./CreateNewWebhookButton";
@@ -1,29 +1,19 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import {
WEBHOOK_VERSION_OPTIONS,
getWebhookVersionLabel,
getWebhookVersionDocsUrl,
} from "@calcom/features/webhooks/lib/constants";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import type { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Select } from "@calcom/ui/components/form";
import { ExternalLinkIcon } from "@coss/ui/icons";
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { toastManager } from "@coss/ui/components/toast";
import { useRouter } from "next/navigation";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
import WebhookForm from "../components/WebhookForm";
import { WebhookVersionCTA } from "../components/WebhookVersionCTA";
import { WebhookFormHeader } from "./webhook-form-header";
import { WebhookFormSkeleton } from "./webhook-form-skeleton";
type WebhookProps = {
id: string;
@@ -40,7 +30,6 @@ type WebhookProps = {
export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
const { t } = useLocale();
const utils = trpc.useUtils();
const router = useRouter();
const { data: installedApps, isPending } = trpc.viewer.apps.integrations.useQuery(
{ variant: "other", onlyInstalled: true },
@@ -55,63 +44,27 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
enabled: !!webhook,
});
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
await utils.viewer.webhook.list.invalidate();
await utils.viewer.webhook.get.invalidate({ webhookId: webhook?.id });
showToast(t("webhook_updated_successfully"), "success");
revalidateWebhooksList();
onSuccess() {
toastManager.add({ title: t("webhook_updated_successfully"), type: "success" });
router.push("/settings/developer/webhooks");
revalidateWebhooksList();
},
onError(error) {
showToast(`${error.message}`, "error");
toastManager.add({ title: error.message, type: "error" });
},
});
if (isPending || !webhook) return <SkeletonContainer />;
if (isPending || !webhook) return <WebhookFormSkeleton titleKey="edit_webhook" />;
return (
<WebhookForm
noRoutingFormTriggers={false}
webhook={webhook}
headerWrapper={(formMethods, children) => (
<SettingsHeaderWithBackButton
title={t("edit_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
CTA={
<div className="flex items-center gap-2">
<Tooltip content={t("webhook_version")}>
<div>
<Select
className="min-w-36"
options={WEBHOOK_VERSION_OPTIONS}
value={{
value: formMethods.watch("version"),
label: getWebhookVersionLabel(formMethods.watch("version")),
}}
onChange={(option) => {
if (option) {
formMethods.setValue("version", option.value, { shouldDirty: true });
}
}}
/>
</div>
</Tooltip>
<Tooltip
content={t("webhook_version_docs", {
version: getWebhookVersionLabel(formMethods.watch("version")),
})}>
<Link
href={getWebhookVersionDocsUrl(formMethods.watch("version"))}
target="_blank"
className="text-subtle hover:text-emphasis flex items-center">
<ExternalLinkIcon className="h-4 w-4" />
</Link>
</Tooltip>
</div>
}>
<>
<WebhookFormHeader titleKey="edit_webhook" CTA={<WebhookVersionCTA formMethods={formMethods} />} />
{children}
</SettingsHeaderWithBackButton>
</>
)}
onSubmit={(values: WebhookFormSubmitData) => {
if (
@@ -124,7 +77,7 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
platform: webhook.platform ?? undefined,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
toastManager.add({ title: t("webhook_subscriber_url_reserved"), type: "error" });
return;
}
@@ -139,7 +92,9 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
editWebhookMutation.mutate({
id: webhook.id,
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
eventTriggers: values.eventTriggers.filter((trigger) =>
WEBHOOK_TRIGGER_EVENTS.includes(trigger as (typeof WEBHOOK_TRIGGER_EVENTS)[number])
) as unknown as Parameters<typeof editWebhookMutation.mutate>[0]["eventTriggers"],
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
@@ -0,0 +1,48 @@
"use client";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@coss/ui/components/button";
import {
AppHeader,
AppHeaderActions,
AppHeaderContent,
AppHeaderDescription,
} from "@coss/ui/shared/app-header";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
type WebhookFormHeaderProps = {
CTA?: ReactNode;
titleKey?: "add_webhook" | "edit_webhook" | "create_webhook";
showBackButton?: boolean;
};
export function WebhookFormHeader({
CTA,
titleKey = "add_webhook",
showBackButton = true,
}: WebhookFormHeaderProps) {
const { t } = useLocale();
return (
<AppHeader>
<div className="flex min-w-0 items-start gap-3">
{showBackButton && (
<Button
aria-label={t("go_back")}
render={<Link href="/settings/developer/webhooks" />}
size="icon-sm"
variant="ghost">
<ArrowLeftIcon />
</Button>
)}
<AppHeaderContent title={t(titleKey)}>
<AppHeaderDescription>{t("add_webhook_description", { appName: APP_NAME })}</AppHeaderDescription>
</AppHeaderContent>
</div>
{CTA && <AppHeaderActions>{CTA}</AppHeaderActions>}
</AppHeader>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Card, CardFrame, CardFrameFooter, CardPanel } from "@coss/ui/components/card";
import { Skeleton } from "@coss/ui/components/skeleton";
import { WebhookFormHeader } from "./webhook-form-header";
import { WebhookTestHeader } from "./webhook-test-header";
type WebhookFormSkeletonProps = {
titleKey?: "add_webhook" | "edit_webhook";
};
export const WebhookFormSkeleton = ({ titleKey = "add_webhook" }: WebhookFormSkeletonProps = {}) => {
return (
<>
<WebhookFormHeader titleKey={titleKey} />
<div className="flex flex-col gap-4">
<CardFrame>
<Card className="rounded-b-none!">
<CardPanel>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-4.5 sm:h-4 w-28" />
<Skeleton className="h-9 sm:h-8 w-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5.5 sm:h-4.5 w-9.5 sm:w-7.5 rounded-full" />
<Skeleton className="h-4.5 sm:h-4 w-28" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4.5 sm:h-4 w-28" />
<Skeleton className="h-43 w-full" />
<Skeleton className="h-6 w-32" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4.5 sm:h-4 w-28" />
<Skeleton className="h-9 sm:h-8 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4.5 sm:h-4 w-28" />
<Skeleton className="h-9 sm:h-8 w-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5.5 sm:h-4.5 w-9.5 sm:w-7.5 rounded-full" />
<Skeleton className="h-4.5 sm:h-4 w-28" />
</div>
</div>
</CardPanel>
</Card>
<CardFrameFooter className="flex items-center justify-end gap-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-9 sm:h-8 w-32" />
</CardFrameFooter>
</CardFrame>
<CardFrame>
<WebhookTestHeader />
<Card>
<CardPanel>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 my-0.5 w-28" />
<Skeleton className="h-13.5 w-full" />
</div>
</CardPanel>
</Card>
</CardFrame>
</div>
</>
);
};
@@ -1,24 +0,0 @@
"use client";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeaderWithBackButton
title={t("add_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<SkeletonContainer>
<div className="divide-subtle border-subtle stack-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
</SettingsHeaderWithBackButton>
);
};
@@ -1,29 +1,19 @@
"use client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import {
WEBHOOK_VERSION_OPTIONS,
getWebhookVersionLabel,
getWebhookVersionDocsUrl,
} from "@calcom/features/webhooks/lib/constants";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Select } from "@calcom/ui/components/form";
import { ExternalLinkIcon } from "@coss/ui/icons";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { trpc } from "@calcom/trpc/react";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { toastManager } from "@coss/ui/components/toast";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
import WebhookForm from "../components/WebhookForm";
import { WebhookVersionCTA } from "../components/WebhookVersionCTA";
import { WebhookFormHeader } from "./webhook-form-header";
type Props = {
webhooks: RouterOutputs["viewer"]["webhook"]["list"];
@@ -42,13 +32,13 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
async onSuccess() {
showToast(t("webhook_created_successfully"), "success");
toastManager.add({ title: t("webhook_created_successfully"), type: "success" });
await utils.viewer.webhook.list.invalidate();
revalidateWebhooksList();
router.push("/settings/developer/webhooks");
},
onError(error) {
showToast(`${error.message}`, "error");
toastManager.add({ title: error.message, type: "error" });
},
});
@@ -63,7 +53,7 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
platform,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
toastManager.add({ title: t("webhook_subscriber_url_reserved"), type: "error" });
return;
}
@@ -73,7 +63,9 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
createWebhookMutation.mutate({
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
eventTriggers: values.eventTriggers.filter((trigger) =>
WEBHOOK_TRIGGER_EVENTS.includes(trigger as (typeof WEBHOOK_TRIGGER_EVENTS)[number])
) as unknown as Parameters<typeof createWebhookMutation.mutate>[0]["eventTriggers"],
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
@@ -91,44 +83,10 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
onSubmit={onCreateWebhook}
apps={installedApps?.items.map((app) => app.slug)}
headerWrapper={(formMethods, children) => (
<SettingsHeaderWithBackButton
title={t("add_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
CTA={
<div className="flex items-center gap-2">
<Tooltip content={t("webhook_version")}>
<div>
<Select
className="min-w-36"
options={WEBHOOK_VERSION_OPTIONS}
value={{
value: formMethods.watch("version"),
label: getWebhookVersionLabel(formMethods.watch("version")),
}}
onChange={(option) => {
if (option) {
formMethods.setValue("version", option.value, { shouldDirty: true });
}
}}
/>
</div>
</Tooltip>
<Tooltip
content={t("webhook_version_docs", {
version: getWebhookVersionLabel(formMethods.watch("version")),
})}>
<Link
href={getWebhookVersionDocsUrl(formMethods.watch("version"))}
target="_blank"
className="text-subtle hover:text-emphasis flex items-center">
<ExternalLinkIcon className="h-4 w-4" />
</Link>
</Tooltip>
</div>
}>
<>
<WebhookFormHeader CTA={<WebhookVersionCTA formMethods={formMethods} />} />
{children}
</SettingsHeaderWithBackButton>
</>
)}
/>
);
@@ -0,0 +1,25 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
CardFrameDescription,
CardFrameHeader,
CardFrameTitle,
} from "@coss/ui/components/card";
import type { ReactNode } from "react";
export function WebhookTestHeader({ actions }: { actions?: ReactNode }) {
const { t } = useLocale();
return (
<CardFrameHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardFrameTitle>{t("webhook_test")}</CardFrameTitle>
<CardFrameDescription>{t("test_webhook")}</CardFrameDescription>
</div>
{actions}
</div>
</CardFrameHeader>
);
}
@@ -0,0 +1,24 @@
"use client";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
AppHeader,
AppHeaderActions,
AppHeaderContent,
AppHeaderDescription,
} from "@coss/ui/shared/app-header";
import type { ReactNode } from "react";
export function WebhooksHeader({ actions }: { actions?: ReactNode }) {
const { t } = useLocale();
return (
<AppHeader>
<AppHeaderContent title={t("webhooks")}>
<AppHeaderDescription>{t("add_webhook_description", { appName: APP_NAME })}</AppHeaderDescription>
</AppHeaderContent>
{actions ? <AppHeaderActions>{actions}</AppHeaderActions> : null}
</AppHeader>
);
}
@@ -1,24 +1,77 @@
"use client";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
import { Card, CardFrame, CardFrameHeader, CardPanel } from "@coss/ui/components/card";
import { Skeleton } from "@coss/ui/components/skeleton";
import {
ListItem,
ListItemActions,
ListItemBadges,
ListItemContent,
ListItemHeader,
} from "@coss/ui/shared/list-item";
import { WebhooksHeader } from "./webhooks-header";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeader
title={t("webhooks")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
CTA={null}>
<SkeletonContainer>
<div className="divide-subtle border-subtle stack-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
</SettingsHeader>
<>
<WebhooksHeader />
<div className="flex flex-col gap-6">
<section>
<CardFrame>
<CardFrameHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 shrink-0 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</CardFrameHeader>
<Card>
<CardPanel className="p-0">
<WebhookListItemSkeleton />
<WebhookListItemSkeleton />
<WebhookListItemSkeleton />
</CardPanel>
</Card>
</CardFrame>
</section>
</div>
</>
);
};
function WebhookListItemSkeleton() {
return (
<ListItem className="*:px-4">
<ListItemContent>
<ListItemHeader>
<div className="flex items-center gap-2">
<Skeleton className="my-0.5 h-5 w-full max-w-[70%] shrink-0 truncate sm:h-4" />
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 shrink-0 sm:h-4.5" />
</div>
</div>
</ListItemHeader>
<ListItemBadges>
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
<Skeleton className="h-5.5 w-22 shrink-0 sm:h-4.5 sm:w-20" />
</ListItemBadges>
</ListItemContent>
<ListItemActions>
<div className="flex items-center gap-4 max-md:hidden">
<Skeleton className="h-4.5 w-7.5 rounded-full max-md:hidden" />
<div className="flex items-center gap-2">
<Skeleton className="size-9 shrink-0 sm:size-8" />
<Skeleton className="size-9 shrink-0 sm:size-8" />
</div>
</div>
<Skeleton className="size-9 shrink-0 sm:size-8 md:hidden" />
</ListItemActions>
</ListItem>
);
}
@@ -4,11 +4,24 @@ import { useBookerUrl } from "@calcom/features/bookings/hooks/useBookerUrl";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from "@coss/ui/components/frame";
import { Avatar, AvatarImage } from "@coss/ui/components/avatar";
import { Card, CardFrame, CardFrameHeader, CardPanel } from "@coss/ui/components/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@coss/ui/components/empty";
import {
AppHeader,
AppHeaderActions,
AppHeaderContent,
AppHeaderDescription,
} from "@coss/ui/shared/app-header";
import { WebhookIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import { CreateNewWebhookButton, WebhookListItem } from "../components";
type WebhooksByViewer = RouterOutputs["viewer"]["webhook"]["getByViewer"];
@@ -28,66 +41,82 @@ const WebhooksView = ({ data }: Props) => {
const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => {
const { t } = useLocale();
const router = useRouter();
const { profiles, webhookGroups } = webhooksByViewer;
const { webhookGroups } = webhooksByViewer;
const bookerUrl = useBookerUrl();
const hasTeams = profiles && profiles.length > 1;
return (
<Frame>
<FrameHeader className="mb-2 flex-row items-center justify-between">
<div>
<FrameTitle className="text-lg">{t("webhooks")}</FrameTitle>
<FrameDescription>{t("add_webhook_description", { appName: APP_NAME })}</FrameDescription>
</div>
<CreateNewWebhookButton />
</FrameHeader>
{webhookGroups.length ? (
<>
{webhookGroups.map((group) => (
<React.Fragment key={group.teamId}>
{hasTeams && (
<div className="mb-2 mt-4 flex items-center px-1">
<Avatar
alt={group.profile.image || ""}
imageSrc={group.profile.image || `${bookerUrl}/${group.profile.name}/avatar.png`}
size="md"
className="inline-flex justify-center"
/>
<div className="text-emphasis ml-2 flex grow items-center font-bold">
{group.profile.name || ""}
</div>
</div>
)}
{group.webhooks.map((webhook) => (
<FramePanel key={webhook.id} className="mt-2">
<WebhookListItem
webhook={webhook}
lastItem={true}
permissions={{
canEditWebhook: group?.metadata?.canModify ?? false,
canDeleteWebhook: group?.metadata?.canDelete ?? false,
}}
onEditWebhook={() =>
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id}`)
}
/>
</FramePanel>
))}
</React.Fragment>
))}
</>
) : (
<FramePanel>
<EmptyScreen
Icon="link"
headline={t("create_your_first_webhook")}
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
buttonRaw={<CreateNewWebhookButton />}
/>
</FramePanel>
)}
</Frame>
<>
<AppHeader>
<AppHeaderContent title={t("webhooks")}>
<AppHeaderDescription>{t("add_webhook_description", { appName: APP_NAME })}</AppHeaderDescription>
</AppHeaderContent>
{webhookGroups.length > 0 && (
<AppHeaderActions>
<CreateNewWebhookButton />
</AppHeaderActions>
)}
</AppHeader>
<div className="flex flex-col gap-6">
{webhookGroups.length ? (
webhookGroups.map((group) => {
const userName = group.profile.name ?? group.profile.slug ?? "";
const userAvatar =
group.profile.image ??
(group.profile.slug ? `${bookerUrl}/${group.profile.slug}/avatar.png` : undefined);
return (
<section key={group.teamId ?? group.profile.slug ?? ""}>
<CardFrame>
<CardFrameHeader>
<div className="flex items-center gap-2">
<Avatar className="size-5">
{userAvatar ? <AvatarImage alt={userName} src={userAvatar} /> : null}
</Avatar>
<span className="font-medium text-sm">{userName}</span>
</div>
</CardFrameHeader>
<Card>
<CardPanel className="p-0">
{[...group.webhooks]
.sort((a, b) => a.id.localeCompare(b.id))
.map((webhook) => (
<WebhookListItem
key={webhook.id}
webhook={webhook}
editHref={`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id}`}
permissions={{
canEditWebhook: group?.metadata?.canModify ?? false,
canDeleteWebhook: group?.metadata?.canDelete ?? false,
}}
onEditWebhookAction={() =>
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id}`)
}
/>
))}
</CardPanel>
</Card>
</CardFrame>
</section>
);
})
) : (
<Empty className="rounded-xl border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<WebhookIcon />
</EmptyMedia>
<EmptyTitle>{t("create_your_first_webhook")}</EmptyTitle>
<EmptyDescription>
{t("create_your_first_webhook_description", { appName: APP_NAME })}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CreateNewWebhookButton isEmptyState />
</EmptyContent>
</Empty>
)}
</div>
</>
);
};
+5 -1
View File
@@ -10,7 +10,8 @@
"./icons": "./src/icons.tsx",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts"
"./hooks/*": "./src/hooks/*.ts",
"./shared/*": "./src/shared/*.tsx"
},
"typesVersions": {
"*": {
@@ -25,6 +26,9 @@
],
"hooks/*": [
"./src/hooks/*.ts"
],
"shared/*": [
"./src/shared/*.tsx"
]
}
},
@@ -0,0 +1,29 @@
import { cn } from "@coss/ui/lib/utils";
function AppHeader({ className, ...props }: React.ComponentProps<"header">) {
return <header className={cn("mb-6 mt-2 flex items-center md:items-start justify-between gap-4", className)} {...props} />;
}
function AppHeaderContent({
children,
className,
title,
...props
}: React.ComponentProps<"div"> & { title: string }) {
return (
<div className={cn("flex flex-col gap-1", className)} {...props}>
<h1 className="font-heading font-semibold text-lg leading-none">{title}</h1>
{children}
</div>
);
}
function AppHeaderDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p className={cn("text-muted-foreground text-sm max-md:hidden", className)} {...props} />;
}
function AppHeaderActions({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex items-center gap-2", className)} {...props} />;
}
export { AppHeader, AppHeaderActions, AppHeaderContent, AppHeaderDescription };
+111
View File
@@ -0,0 +1,111 @@
import { cn } from "@coss/ui/lib/utils";
import type { ReactNode } from "react";
import Link from "next/link";
interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
className?: string;
}
function ListItem({ children, className, ...props }: ListItemProps) {
return (
<div
{...props}
className={cn(
"not-last:border-b bg-clip-padding transition-[background-color] first:rounded-t-[calc(var(--radius-xl)+1px)] last:rounded-b-[calc(var(--radius-xl)+1px)] has-[[data-slot=list-item-title]_a:hover]:z-1 has-[[data-slot=list-item-title]_a:hover]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] dark:has-[[data-slot=list-item-title]_a:hover]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)]",
className
)}
data-slot="list-item">
<div className="relative flex items-center justify-between gap-4 px-6 py-4">{children}</div>
</div>
);
}
interface ListItemContentProps {
children: ReactNode;
className?: string;
}
function ListItemContent({ children, className }: ListItemContentProps) {
return <div className={cn("flex min-w-0 flex-1 flex-col gap-3", className)}>{children}</div>;
}
interface ListItemHeaderProps {
children: ReactNode;
className?: string;
}
function ListItemHeader({ children, className }: ListItemHeaderProps) {
return <div className={cn("flex flex-col gap-1", className)}>{children}</div>;
}
interface ListItemTitleProps {
children: ReactNode;
className?: string;
}
function ListItemTitle({ children, className }: ListItemTitleProps) {
return (
<h2 className={cn("font-semibold sm:text-sm", className)} data-slot="list-item-title">
{children}
</h2>
);
}
function ListItemTitleLink({ children, className, href }: ListItemTitleProps & { href: string }) {
return (
<Link className={cn("before:absolute before:inset-0", className)} data-slot="list-item-title-link" href={href}>
{children}
</Link>
);
}
interface ListItemDescriptionProps {
children: ReactNode;
className?: string;
}
function ListItemDescription({ children, className }: ListItemDescriptionProps) {
return (
<p className={cn("text-muted-foreground text-sm", className)} data-slot="list-item-description">
{children}
</p>
);
}
interface ListItemBadgesProps {
children: ReactNode;
className?: string;
}
function ListItemBadges({ children, className }: ListItemBadgesProps) {
return (
<div className={cn("flex flex-wrap items-center gap-2", className)} data-slot="list-item-badges">
{children}
</div>
);
}
interface ListItemActionsProps {
children: ReactNode;
className?: string;
}
function ListItemActions({ children, className }: ListItemActionsProps) {
return (
<div className={cn("relative flex items-center gap-4", className)} data-slot="list-item-actions">
{children}
</div>
);
}
export {
ListItem,
ListItemActions,
ListItemBadges,
ListItemContent,
ListItemDescription,
ListItemHeader,
ListItemTitle,
ListItemTitleLink,
};
+54 -46
View File
@@ -66,23 +66,23 @@
:root {
--radius: 0.625rem;
--background: var(--color-white);
--foreground: var(--color-zinc-800);
--foreground: var(--color-neutral-800);
--card: var(--color-white);
--card-foreground: var(--color-zinc-800);
--card-foreground: var(--color-neutral-800);
--popover: var(--color-white);
--popover-foreground: var(--color-zinc-800);
--primary: var(--color-zinc-800);
--primary-foreground: var(--color-zinc-50);
--popover-foreground: var(--color-neutral-800);
--primary: var(--color-neutral-800);
--primary-foreground: var(--color-neutral-50);
--secondary: --alpha(var(--color-black) / 4%);
--secondary-foreground: var(--color-zinc-800);
--secondary-foreground: var(--color-neutral-800);
--muted: --alpha(var(--color-black) / 4%);
--muted-foreground: color-mix(
in srgb,
var(--color-zinc-500) 90%,
var(--color-neutral-500) 90%,
var(--color-black)
);
--accent: --alpha(var(--color-black) / 4%);
--accent-foreground: var(--color-zinc-800);
--accent-foreground: var(--color-neutral-800);
--destructive: var(--color-red-500);
--destructive-foreground: var(--color-red-700);
--info: var(--color-blue-500);
@@ -91,26 +91,26 @@
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
--border: --alpha(var(--color-black) / 12%);
--input: --alpha(var(--color-black) / 12%);
--ring: var(--color-zinc-400);
--border: --alpha(var(--color-black) / 8%);
--input: --alpha(var(--color-black) / 10%);
--ring: var(--color-neutral-400);
--chart-1: var(--color-orange-600);
--chart-2: var(--color-teal-600);
--chart-3: var(--color-cyan-900);
--chart-4: var(--color-amber-400);
--chart-5: var(--color-amber-500);
--sidebar: var(--color-zinc-50);
--sidebar: var(--color-neutral-50);
--sidebar-foreground: color-mix(
in srgb,
var(--color-zinc-800) 64%,
var(--color-neutral-800) 64%,
var(--sidebar)
);
--sidebar-primary: var(--color-zinc-800);
--sidebar-primary-foreground: var(--color-zinc-50);
--sidebar-primary: var(--color-neutral-800);
--sidebar-primary-foreground: var(--color-neutral-50);
--sidebar-accent: --alpha(var(--color-black) / 4%);
--sidebar-accent-foreground: var(--color-zinc-800);
--sidebar-border: --alpha(var(--color-black) / 12%);
--sidebar-ring: var(--color-zinc-400);
--sidebar-accent-foreground: var(--color-neutral-800);
--sidebar-border: --alpha(var(--color-black) / 6%);
--sidebar-ring: var(--color-neutral-400);
--code: var(--color-white);
--code-foreground: var(--foreground);
--code-highlight: --alpha(var(--color-black) / 4%);
@@ -119,27 +119,31 @@
.dark {
--background: color-mix(
in srgb,
var(--color-zinc-950) 98%,
var(--color-neutral-950) 96%,
var(--color-white)
);
--foreground: var(--color-zinc-200);
--foreground: var(--color-neutral-100);
--card: color-mix(in srgb, var(--background) 98%, var(--color-white));
--card-foreground: var(--color-zinc-200);
--card-foreground: var(--color-neutral-100);
--popover: color-mix(in srgb, var(--background) 96%, var(--color-white));
--popover-foreground: var(--color-zinc-200);
--primary: var(--color-zinc-200);
--primary-foreground: var(--color-zinc-800);
--secondary: --alpha(var(--color-white) / 6%);
--secondary-foreground: var(--color-zinc-200);
--muted: --alpha(var(--color-white) / 6%);
--popover-foreground: var(--color-neutral-100);
--primary: var(--color-neutral-100);
--primary-foreground: var(--color-neutral-800);
--secondary: --alpha(var(--color-white) / 4%);
--secondary-foreground: var(--color-neutral-100);
--muted: --alpha(var(--color-white) / 4%);
--muted-foreground: color-mix(
in srgb,
var(--color-zinc-500) 90%,
var(--color-neutral-500) 90%,
var(--color-white)
);
--accent: --alpha(var(--color-white) / 4%);
--accent-foreground: var(--color-neutral-100);
--destructive: color-mix(
in srgb,
var(--color-red-500) 90%,
var(--color-white)
);
--accent: --alpha(var(--color-white) / 6%);
--accent-foreground: var(--color-zinc-200);
--destructive: var(--color-red-500);
--destructive-foreground: var(--color-red-400);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-400);
@@ -147,29 +151,33 @@
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
--border: --alpha(var(--color-white) / 12%);
--input: --alpha(var(--color-white) / 12%);
--ring: var(--color-zinc-500);
--border: --alpha(var(--color-white) / 6%);
--input: --alpha(var(--color-white) / 8%);
--ring: var(--color-neutral-500);
--chart-1: var(--color-blue-700);
--chart-2: var(--color-emerald-500);
--chart-3: var(--color-amber-500);
--chart-4: var(--color-purple-500);
--chart-5: var(--color-rose-500);
--sidebar: var(--color-zinc-950);
--sidebar: color-mix(
in srgb,
var(--color-neutral-950) 97%,
var(--color-white)
);
--sidebar-foreground: color-mix(
in srgb,
var(--color-zinc-200) 64%,
var(--color-neutral-100) 64%,
var(--sidebar)
);
--sidebar-primary: var(--color-zinc-200);
--sidebar-primary-foreground: var(--color-zinc-800);
--sidebar-accent: --alpha(var(--color-white) / 6%);
--sidebar-accent-foreground: var(--color-zinc-200);
--sidebar-border: --alpha(var(--color-white) / 12%);
--sidebar-ring: var(--color-zinc-400);
--code: color-mix(in srgb, var(--background) 96%, var(--color-white));
--sidebar-primary: var(--color-neutral-100);
--sidebar-primary-foreground: var(--color-neutral-800);
--sidebar-accent: --alpha(var(--color-white) / 4%);
--sidebar-accent-foreground: var(--color-neutral-100);
--sidebar-border: --alpha(var(--color-white) / 5%);
--sidebar-ring: var(--color-neutral-400);
--code: color-mix(in srgb, var(--background) 98%, var(--color-white));
--code-foreground: var(--foreground);
--code-highlight: --alpha(var(--color-white) / 6%);
--code-highlight: --alpha(var(--color-white) / 4%);
}
@layer base {
@@ -179,5 +187,5 @@
}
.backdrop-blur-sm {
@apply backdrop-blur-none bg-neutral-800/70;
}
@apply backdrop-blur-none bg-black/70;
}
+1
View File
@@ -2127,6 +2127,7 @@
"triggers_when": "Triggers when",
"test_webhook": "Please ping test before creating.",
"enable_webhook": "Enable webhook",
"disable_webhook": "Disable webhook",
"add_webhook": "Add webhook",
"webhook_edited_successfully": "Webhook saved",
"api_keys_description": "Generate API keys for accessing your own account",