import { zodResolver } from '@hookform/resolvers/zod' import { ChevronDown } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Checkbox, cn, Form, FormControl, FormField, Input, Label, Separator, Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, Textarea, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' import { getKeyValueFieldArrayValidationIssues, stripEmptyKeyValueFieldArrayRows, } from 'ui-patterns/form/KeyValueFieldArray/validation' import * as z from 'zod' import type { UpsertWebhookEndpointInput, WebhookEndpoint, WebhookScope, } from './PlatformWebhooks.types' import { generateWebhookEndpointName } from './PlatformWebhooks.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { InlineLink } from '@/components/ui/InlineLink' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { httpEndpointUrlSchema } from '@/lib/validation/http-url' const endpointFormSchema = z .object({ name: z.string().trim().max(64, 'Name cannot exceed 64 characters'), url: httpEndpointUrlSchema({ requiredMessage: 'Please provide a URL', invalidMessage: 'Please provide a valid URL', prefixMessage: 'Please prefix your URL with http:// or https://', }), description: z.string().trim().max(512, 'Description cannot exceed 512 characters'), enabled: z.boolean().default(true), subscribeAll: z.boolean().default(false), eventTypes: z.array(z.string()).default([]), customHeaders: z .array( z.object({ key: z.string().trim(), value: z.string().trim(), }) ) .default([]), }) .superRefine((data, ctx) => { if (!data.subscribeAll && data.eventTypes.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Select at least one event type', path: ['eventTypes'], }) } getKeyValueFieldArrayValidationIssues({ rows: data.customHeaders, keyFieldName: 'key', valueFieldName: 'value', keyRequiredMessage: 'Header name is required', valueRequiredMessage: 'Header value is required', }).forEach((issue) => { ctx.addIssue({ code: z.ZodIssueCode.custom, message: issue.message, path: ['customHeaders', ...issue.path], }) }) }) export type EndpointFormValues = z.infer const toEventTypes = (values: EndpointFormValues) => values.subscribeAll ? ['*'] : values.eventTypes type EventTypeGroup = { id: string label: string eventTypes: string[] } const buildEventTypeGroups = (scope: WebhookScope, eventTypes: string[]): EventTypeGroup[] => { if (scope === 'project') { return [{ id: 'project', label: 'Project events', eventTypes }] } const organizationEvents = eventTypes.filter((eventType) => eventType.startsWith('organization.')) const projectEvents = eventTypes.filter((eventType) => eventType.startsWith('project.')) const ungroupedEvents = eventTypes.filter( (eventType) => !eventType.startsWith('organization.') && !eventType.startsWith('project.') ) return [ { id: 'organization', label: 'Organization events', eventTypes: organizationEvents }, { id: 'project', label: 'Project events', eventTypes: projectEvents }, { id: 'other', label: 'Other events', eventTypes: ungroupedEvents }, ].filter((group) => group.eventTypes.length > 0) } const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => { if (checked) return [...new Set([...selectedEventTypes, eventType])] return selectedEventTypes.filter((value) => value !== eventType) } const toggleEventTypeGroup = ( selectedEventTypes: string[], groupedEventTypes: string[], checked: boolean ) => { if (checked) return [...new Set([...selectedEventTypes, ...groupedEventTypes])] return selectedEventTypes.filter((value) => !groupedEventTypes.includes(value)) } const toControlId = (prefix: string, value: string) => `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}` export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({ name: values.name, url: values.url, description: values.description, enabled: values.enabled, eventTypes: toEventTypes(values), customHeaders: stripEmptyKeyValueFieldArrayRows({ rows: values.customHeaders, keyFieldName: 'key', valueFieldName: 'value', }), }) interface EndpointSheetProps { visible: boolean mode: 'create' | 'edit' scope: WebhookScope orgSlug?: string endpoint?: WebhookEndpoint enabledOverride?: boolean | null eventTypes: string[] onClose: () => void onSubmit: (values: EndpointFormValues) => void } export const PlatformWebhooksEndpointSheet = ({ visible, mode, scope, orgSlug, endpoint, enabledOverride, eventTypes, onClose, onSubmit, }: EndpointSheetProps) => { const form = useForm({ resolver: zodResolver(endpointFormSchema as any), defaultValues: { name: generateWebhookEndpointName(), url: '', description: '', enabled: true, subscribeAll: false, eventTypes: [], customHeaders: [], }, }) const isDirty = form.formState.isDirty const { confirmOnClose, handleOpenChange, modalProps: discardChangesModalProps, } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose, }) const subscribeAll = form.watch('subscribeAll') const selectedEventTypes = form.watch('eventTypes') const groupedEventTypes = useMemo( () => buildEventTypeGroups(scope, eventTypes), [scope, eventTypes] ) const [openEventGroups, setOpenEventGroups] = useState([]) useEffect(() => { if (!visible) return if (!endpoint) { form.reset({ name: generateWebhookEndpointName(), url: '', description: '', enabled: true, subscribeAll: false, eventTypes: [], customHeaders: [], }) return } form.reset({ name: endpoint.name, url: endpoint.url, description: endpoint.description, enabled: enabledOverride ?? endpoint.enabled, subscribeAll: endpoint.eventTypes.includes('*'), eventTypes: endpoint.eventTypes.includes('*') ? eventTypes : endpoint.eventTypes, customHeaders: endpoint.customHeaders.map((header) => ({ key: header.key, value: header.value, })), }) }, [enabledOverride, endpoint, eventTypes, form, visible]) useEffect(() => { if (!visible) return setOpenEventGroups(groupedEventTypes.map((group) => group.id)) }, [groupedEventTypes, visible]) useEffect(() => { if (!visible) return const allSelected = eventTypes.length > 0 && eventTypes.every((eventType) => selectedEventTypes.includes(eventType)) if (subscribeAll !== allSelected) { form.setValue('subscribeAll', allSelected, { shouldDirty: true, shouldValidate: true, }) } }, [eventTypes, form, selectedEventTypes, subscribeAll, visible]) return ( {mode === 'create' ? 'Create endpoint' : 'Edit endpoint'} {mode === 'create' ? 'Create a webhook endpoint by setting a name, URL, and event subscriptions.' : 'Edit this webhook endpoint name, URL, and event subscriptions.'}
( Name {/* Technically optional but encourage, so no (optional) label */} } layout="vertical" className="gap-1" > )} /> ( )} /> ( Description (optional) } layout="vertical" className="gap-1" >