Files
calendar/apps/web/modules/webhooks/components/WebhookForm.tsx
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Opus 4.5
48cbb94af2 feat: add webhook trigger when routing form fallback route is hit (#27588)
* feat: add no users found fallback action for routing forms

- Add NoUsersFoundFallbackActionType enum with CustomPageMessage and ExternalRedirectUrl options
- Add noUsersFoundFallbackAction field to zodNonRouterRoute schema
- Add NoUsersFoundFallback UI component in RouteBuilder for configuring fallback action
- Update handleResponse to return noUsersFoundFallbackAction when no team members match
- Add i18n translation keys for new UI strings

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: change fallback to use same action options as main route

- Replace noUsersFoundFallbackAction with fallbackAction that has same structure as main action
- Add action dropdown and input fields to fallback attributes query builder section
- Support Event redirect, External URL, and Custom page options in fallback
- Remove separate NoUsersFoundFallback toggle component
- Update handleResponse to return fallbackAction

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: ensure fallbackAction type is always defined in onChange handlers

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add guards for route.fallbackAction in onChange handlers

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: only return fallbackAction when no users are found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: restructure fallback section to mirror main route structure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: ensure backwards compatibility for existing routes with fallbackAttributesQueryValue

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: initialize fallbackAction with main event type for backwards compatibility

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: include fallbackAction in getRoutesToSave to persist changes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use fallbackAction in getRoutedUrl when no team members found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use i18n for fallback label and add tests for fallback action

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: do not trigger fallback action when CRM contact owner is found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: prioritize fallbackAction over fallbackAttributesQueryValue

When a route has a fallbackAction configured, skip the
fallbackAttributesQueryValue to ensure the fallbackAction is triggered
when no team members are found. This maintains backwards compatibility
by only using fallbackAttributesQueryValue when fallbackAction is not set.

Also treats teamMemberIdsMatchingAttributeLogic being null (routing couldn't
run, e.g., missing orgId) the same as an empty array for the purpose of
triggering the fallbackAction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract RouteActionSelector shared component for action selection UI

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add i18n string for default custom page message

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove webhook object from error log to avoid exposing secrets

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* revert: remove webhook trigger changes (to be moved to separate PR)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove webhook object from error log to avoid exposing secrets

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add ROUTING_FORM_FALLBACK_HIT to PayloadBuilderFactory trigger mapping

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* style: apply biome formatting fixes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: restore removed comments in RouteBuilder

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* chore: add prisma migration for ROUTING_FORM_FALLBACK_HIT enum value

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add ROUTING_FORM_FALLBACK_HIT to FormTriggerEvents type union

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add test coverage for triggerFallbackWebhook function

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: consolidate fallback webhook firing with form submission webhooks

Move ROUTING_FORM_FALLBACK_HIT webhooks into the same Promise.all() batch
as FORM_SUBMITTED / FORM_SUBMITTED_NO_EVENT, eliminating redundant
getWebhooks/getWebhookTargetEntity/getOrgIdFromMemberOrTeamId calls.

- Add fallbackAction parameter to _onFormSubmission and onSubmissionOfFormResponse
- Remove triggerFallbackWebhook function and all its usages/imports
- Move getFallbackAction() earlier in handleResponse.ts and pass result through
- Update all tests to verify batched webhook behavior

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add UI option for trigger

* feat: persist fallbackAction on queued form responses

When a routing form response is queued, the fallbackAction determined
during routing is now stored on the QueuedFormResponse record. When
the queued response is later converted to a real FormResponse, the
stored fallbackAction is passed to onSubmissionOfFormResponse so the
ROUTING_FORM_FALLBACK_HIT webhook fires correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add coverage for fallbackAction in queued form response flow

Verify that fallbackAction is persisted when queuing and passed through
to onSubmissionOfFormResponse when the queued response is converted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 16:44:41 -05:00

639 lines
23 KiB
TypeScript

"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 WebhookTestDisclosure from "./WebhookTestDisclosure";
export type TWebhook = RouterOutputs["viewer"]["webhook"]["list"][number];
export type WebhookFormData = {
id?: string;
subscriberUrl: string;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
payloadTemplate: string | undefined | null;
time?: number | null;
timeUnit?: TimeUnit | null;
version: WebhookVersion;
};
export type WebhookFormSubmitData = WebhookFormData & {
changeSecret: boolean;
newSecret: string;
};
type WebhookTriggerEventOptions = readonly { value: WebhookTriggerEvents; label: string }[];
const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEventOptions> = {
core: [
{ value: WebhookTriggerEvents.BOOKING_CANCELLED, label: "booking_cancelled" },
{ value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" },
{ value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" },
{ value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" },
{ value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" },
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
{ value: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, label: "booking_no_show_updated" },
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
{ value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting" },
{ value: WebhookTriggerEvents.OOO_CREATED, label: "ooo_created" },
{
value: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
label: "recording_transcription_generated",
},
{ value: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, label: "after_hosts_cal_video_no_show" },
{
value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
label: "after_guests_cal_video_no_show",
},
{ value: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, label: "wrong_assignment_report" },
],
"routing-forms": [
{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" },
{ value: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, label: "form_submitted_no_event" },
{ value: WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT, label: "routing_form_fallback_hit_trigger" },
],
} as const;
function getWebhookVariables(t: (key: string) => string) {
return [
{
category: t("webhook_event_and_booking"),
variables: [
{
name: "triggerEvent",
variable: "{{triggerEvent}}",
type: "String",
description: t("webhook_trigger_event"),
},
{
name: "createdAt",
variable: "{{createdAt}}",
type: "Datetime",
description: t("webhook_created_at"),
},
{ name: "type", variable: "{{type}}", type: "String", description: t("webhook_type") },
{ name: "title", variable: "{{title}}", type: "String", description: t("webhook_title") },
{
name: "startTime",
variable: "{{startTime}}",
type: "Datetime",
description: t("webhook_start_time"),
},
{
name: "endTime",
variable: "{{endTime}}",
type: "Datetime",
description: t("webhook_end_time"),
},
{
name: "description",
variable: "{{description}}",
type: "String",
description: t("webhook_description"),
},
{
name: "location",
variable: "{{location}}",
type: "String",
description: t("webhook_location"),
},
{ name: "uid", variable: "{{uid}}", type: "String", description: t("webhook_uid") },
{
name: "rescheduleUid",
variable: "{{rescheduleUid}}",
type: "String",
description: t("webhook_reschedule_uid"),
},
{
name: "cancellationReason",
variable: "{{cancellationReason}}",
type: "String",
description: t("webhook_cancellation_reason"),
},
{
name: "rejectionReason",
variable: "{{rejectionReason}}",
type: "String",
description: t("webhook_rejection_reason"),
},
],
},
{
category: t("webhook_people"),
variables: [
{
name: "organizer.name",
variable: "{{organizer.name}}",
type: "String",
description: t("webhook_organizer_name"),
},
{
name: "organizer.email",
variable: "{{organizer.email}}",
type: "String",
description: t("webhook_organizer_email"),
},
{
name: "organizer.timezone",
variable: "{{organizer.timezone}}",
type: "String",
description: t("webhook_organizer_timezone"),
},
{
name: "organizer.language.locale",
variable: "{{organizer.language.locale}}",
type: "String",
description: t("webhook_organizer_locale"),
},
{
name: "organizer.username",
variable: "{{organizer.username}}",
type: "String",
description: t("webhook_organizer_username"),
},
{
name: "organizer.usernameInOrg",
variable: "{{organizer.usernameInOrg}}",
type: "String",
description: t("webhook_organizer_username_in_org"),
},
{
name: "attendees.0.name",
variable: "{{attendees.0.name}}",
type: "String",
description: t("webhook_attendee_name"),
},
{
name: "attendees.0.email",
variable: "{{attendees.0.email}}",
type: "String",
description: t("webhook_attendee_email"),
},
{
name: "attendees.0.timeZone",
variable: "{{attendees.0.timeZone}}",
type: "String",
description: t("webhook_attendee_timezone"),
},
{
name: "attendees.0.language.locale",
variable: "{{attendees.0.language.locale}}",
type: "String",
description: t("webhook_attendee_locale"),
},
],
},
{
category: t("webhook_teams"),
variables: [
{
name: "team.name",
variable: "{{team.name}}",
type: "String",
description: t("webhook_team_name"),
},
{
name: "team.members",
variable: "{{team.members}}",
type: "String[]",
description: t("webhook_team_members"),
},
],
},
{
category: t("webhook_metadata"),
variables: [
{
name: "metadata.videoCallUrl",
variable: "{{metadata.videoCallUrl}}",
type: "String",
description: t("webhook_video_call_url"),
},
],
},
];
}
export type WebhookFormValues = {
subscriberUrl: string;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
payloadTemplate: string | undefined | null;
time?: number | null;
timeUnit?: TimeUnit | null;
version: WebhookVersion;
};
const WebhookForm = (props: {
webhook?: TWebhook | WebhookFormData;
apps?: (keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)[];
overrideTriggerOptions?: (typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)["core"];
onSubmit: (event: WebhookFormSubmitData) => void;
onCancel?: () => void;
noRoutingFormTriggers: boolean;
selectOnlyInstantMeetingOption?: boolean;
headerWrapper?: (
formMethods: ReturnType<typeof useForm<WebhookFormValues>>,
children: React.ReactNode
) => React.ReactNode;
}) => {
const { apps = [], selectOnlyInstantMeetingOption = false, overrideTriggerOptions } = props;
const { t } = useLocale();
const webhookVariables = getWebhookVariables(t);
const triggerOptions = overrideTriggerOptions
? [...overrideTriggerOptions]
: [...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2["core"]];
if (apps) {
for (const app of apps) {
if (app === "routing-forms" && props.noRoutingFormTriggers) continue;
if (WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]) {
triggerOptions.push(...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]);
}
}
}
const translatedTriggerOptions = triggerOptions.map((option) => ({ ...option, label: t(option.label) }));
const getEventTriggers = () => {
if (props.webhook) return props.webhook.eventTriggers;
return (
selectOnlyInstantMeetingOption
? translatedTriggerOptions.filter((option) => option.value === WebhookTriggerEvents.INSTANT_MEETING)
: translatedTriggerOptions.filter((option) => option.value !== WebhookTriggerEvents.INSTANT_MEETING)
).map((option) => option.value);
};
const formMethods = useForm<WebhookFormValues>({
defaultValues: {
subscriberUrl: props.webhook?.subscriberUrl || "",
active: props.webhook ? props.webhook.active : true,
eventTriggers: getEventTriggers(),
secret: props?.webhook?.secret || "",
payloadTemplate: props?.webhook?.payloadTemplate || undefined,
timeUnit: props?.webhook?.timeUnit || undefined,
time: props?.webhook?.time || undefined,
version: props?.webhook?.version || WebhookVersion.V_2021_10_20,
},
});
formMethods.register("version");
const triggers = formMethods.watch("eventTriggers") || [];
const subscriberUrl = formMethods.watch("subscriberUrl");
const time = formMethods.watch("time");
const timeUnit = formMethods.watch("timeUnit");
const isCreating = !props?.webhook?.id;
const needsTime = triggers.some(
(t) =>
t === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
t === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
);
const hasTime = !!time && !!timeUnit;
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 || "{}");
parsed[name] = value;
return JSON.stringify(parsed, null, 2);
} catch {
const trimmed = current.trim();
if (trimmed === "{}" || trimmed === "") {
return `{\n "${name}": "${value}"\n}`;
}
if (trimmed.endsWith("}")) {
const withoutClosing = trimmed.slice(0, -1);
const needsComma = withoutClosing.trim().endsWith('"') || withoutClosing.trim().endsWith("}");
return `${withoutClosing}${needsComma ? "," : ""}\n "${name}": "${value}"\n}`;
}
return `${current}\n"${name}": "${value}"`;
}
}
const [showVariables, setShowVariables] = useState(false);
const [newSecret, setNewSecret] = useState("");
const [changeSecret, setChangeSecret] = useState<boolean>(false);
const hasSecretKey = !!props?.webhook?.secret;
const canSubmit = isCreating
? hasUrl && triggers.length > 0 && (!needsTime || hasTime)
: formMethods.formState.isDirty || changeSecret;
useEffect(() => {
if (isCreating && needsTime && !time && !timeUnit) {
formMethods.setValue("time", 5, { shouldDirty: true });
formMethods.setValue("timeUnit", TimeUnit.MINUTE, { shouldDirty: true });
}
}, [isCreating, needsTime, time, timeUnit, formMethods]);
useEffect(() => {
if (changeSecret) {
formMethods.unregister("secret", { keepDefaultValue: false });
}
}, [changeSecret, formMethods]);
const formContent = (
<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>
<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>
</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;