diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/loading.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/loading.tsx new file mode 100644 index 0000000000..0a38211f31 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/loading.tsx @@ -0,0 +1,5 @@ +import { WebhookFormSkeleton } from "~/webhooks/views/webhook-form-skeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/loading.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/loading.tsx index 75482db52b..3b561a591c 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/loading.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/loading.tsx @@ -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 ; + return ; } diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index db18f72e4d..257e3734b6 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -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) { - {!isEmbed && !isBookingPage && } - {/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */} - - {children} - + + {!isEmbed && !isBookingPage && } + {/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */} + + {children} + + diff --git a/apps/web/modules/event-types/components/tabs/instant/InstantEventController.tsx b/apps/web/modules/event-types/components/tabs/instant/InstantEventController.tsx index fa829607c0..9be367f152 100644 --- a/apps/web/modules/event-types/components/tabs/instant/InstantEventController.tsx +++ b/apps/web/modules/event-types/components/tabs/instant/InstantEventController.tsx @@ -374,9 +374,8 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) => { + onEditWebhookAction={() => { setEditModalOpen(true); setWebhookToEdit(webhook); }} diff --git a/apps/web/modules/event-types/components/tabs/webhooks/EventWebhooksTab.tsx b/apps/web/modules/event-types/components/tabs/webhooks/EventWebhooksTab.tsx index 816f56b51e..b974bed5da 100644 --- a/apps/web/modules/event-types/components/tabs/webhooks/EventWebhooksTab.tsx +++ b/apps/web/modules/event-types/components/tabs/webhooks/EventWebhooksTab.tsx @@ -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) => { const { t } = useLocale(); @@ -90,6 +95,7 @@ export const EventWebhooksTab = ({ eventType }: Pick
- <> - {(isManagedEventType || isChildrenManagedEventType) && ( - - } - actions={ -
{webhooksDisableProps.LockedIcon}
- } - message={ - - } - /> - )} - {webhooks.length ? ( - <> -
-
-
-
{t("webhooks")}
-

- {t("add_webhook_description", { appName: APP_NAME })} -

-
- {cannotEditWebhooks ? ( - - ) : ( - - )} -
- -
- {webhooks.map((webhook, index) => { - return ( - { - setEditModalOpen(true); - setWebhookToEdit(webhook); - }} - readOnly={isChildrenManagedEventType && webhook.eventTypeId !== eventType.id} - /> - ); - })} -
- -

- - webhooks settings - , - ]} - /> -

-
- - ) : ( - + } + actions={
{webhooksDisableProps.LockedIcon}
} + message={ + + } + /> + )} + {webhooks.length ? ( + <> + + + + {t("add_webhook_description", { appName: APP_NAME })} + + + + {cannotEditWebhooks ? ( ) : ( - ) - } - /> - )} - + )} + + + +
+ + + {webhooks.map((webhook, index) => { + const readOnly = isChildrenManagedEventType && webhook.eventTypeId !== eventType.id; + return ( + { + setEditModalOpen(true); + setWebhookToEdit(webhook); + }} + /> + ); + })} + + + +

+ , + ]} + /> +

+
+ + ) : ( + + {t("locked_by_team_admin")} + + ) : ( + + ) + } + /> + )}
{/* New webhook dialog */} - !isOpen && setCreateModalOpen(false)}> - - setCreateModalOpen(false)} - apps={installedApps?.items.map((app) => app.slug)} - /> - + + + + setCreateModalOpen(false)} + apps={installedApps?.items.map((app) => app.slug)} + headerWrapper={(formMethods, children) => ( + <> + } + /> + {children} + + )} + /> + + {/* Edit webhook dialog */} - !isOpen && setEditModalOpen(false)}> - - app.slug)} - onCancel={() => setEditModalOpen(false)} - onSubmit={(values: WebhookFormSubmitData) => { - if ( - subscriberUrlReserved({ + + + + app.slug)} + onCancel={() => setEditModalOpen(false)} + headerWrapper={(formMethods, children) => ( + <> + } + /> + {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, - }); - }} - /> - + 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, + }); + }} + /> + + )} diff --git a/apps/web/modules/webhooks/components/CreateNewWebhookButton.tsx b/apps/web/modules/webhooks/components/CreateNewWebhookButton.tsx index 7ab2a5de6f..27df40ac89 100644 --- a/apps/web/modules/webhooks/components/CreateNewWebhookButton.tsx +++ b/apps/web/modules/webhooks/components/CreateNewWebhookButton.tsx @@ -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 ( + + ); + } + return ( - + + }> + {t("new")} + + + + {t("create_for")} + {options.map((option, idx) => ( + handleSelect(option)}> + + + {option.image ? : null} + + {getInitials(option.label || "")} + + + {option.label} + + + ))} + + + ); }; diff --git a/apps/web/modules/webhooks/components/EventTypeWebhookListItem.tsx b/apps/web/modules/webhooks/components/EventTypeWebhookListItem.tsx deleted file mode 100644 index 5eb0b4e43f..0000000000 --- a/apps/web/modules/webhooks/components/EventTypeWebhookListItem.tsx +++ /dev/null @@ -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 ( -
-
-
- -

- {webhook.subscriberUrl} -

-
- {!!props.readOnly && ( - - {t("readonly")} - - )} -
- -
- {webhook.eventTriggers.map((trigger) => ( - - {t(`${trigger.toLowerCase()}`)} - - ))} -
-
-
- {!props.readOnly && ( -
- - toggleWebhook.mutate({ - id: webhook.id, - active: !webhook.active, - payloadTemplate: webhook.payloadTemplate, - eventTypeId: webhook.eventTypeId || undefined, - }) - } - /> - - - -
- )} - - { - deleteWebhook.mutate({ - id: webhook.id, - eventTypeId: webhook.eventTypeId || undefined, - teamId: webhook.teamId || undefined, - }); - }} - /> -
- ); -} diff --git a/apps/web/modules/webhooks/components/WebhookForm.tsx b/apps/web/modules/webhooks/components/WebhookForm.tsx index 978427928a..9ae9c98b9a 100644 --- a/apps/web/modules/webhooks/components/WebhookForm.tsx +++ b/apps/web/modules/webhooks/components/WebhookForm.tsx @@ -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(false); @@ -365,274 +383,401 @@ const WebhookForm = (props: { } }, [changeSecret, formMethods]); - const formContent = ( + const formFields = ( +
+ ( + + {t("subscriber_url")} + { + onChange(urlValue); + if (urlValue && hasTemplateIntegration({ url: urlValue })) { + setCustomPayloadOpen(true); + formMethods.setValue("payloadTemplate", customTemplate({ url: urlValue }), { + shouldDirty: true, + }); + } + }} + /> + {error?.message} + + )} + /> + ( + + + onChange(checked)} /> + {t("enable_webhook")} + + + )} + /> + { + const selectedItems = translatedTriggerOptions.filter((option) => value.includes(option.value)); + return ( + + {t("event_triggers")} + { + 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> + + + {(selectedValues: { value: WebhookTriggerEvents; label: string }[]) => ( + <> + {selectedValues?.map((item) => ( + + {item.label} + + ))} + 0 + ? undefined + : t("select_event_triggers", "Select event triggers…") + } + /> + + )} + + + + {t("no_options_available")} + + {(item: { value: WebhookTriggerEvents; label: string }) => ( + + {item.label} + + )} + + + }> + + {t("clear_all_triggers", "Clear all triggers")} + + + + ); + }} + /> + + {showTimeSection && ( + ( + ( + + {t("how_long_after_user_no_show_minutes")} + + onTimeChange(newValue ?? 5)} + render={}> + + + + + + + )} + /> + )} + /> + )} + + ( + + {hasSecretKey ? ( + <> + {t("secret")} +
+ { + setNewSecret(v); + onChange(v); + } + : undefined + } + placeholder={changeSecret ? t("leave_blank_to_remove_secret") : undefined} + className="flex-1" + /> + +
+ {t("forgotten_secret_description")} + + ) : ( + <> + {t("secret")} + + + )} +
+ )} + /> + + { + // 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 ( + + + + { + setCustomPayloadOpen(checked); + if (!checked) onChange(undefined); + }} + /> + } + /> + {t("custom_payload_template", "Custom Payload Template")} + + + +
+