PlatformWebhooksEndpointSheet.tsx594 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { ChevronDown } from 'lucide-react'
3import { useEffect, useMemo, useState } from 'react'
4import { useForm } from 'react-hook-form'
5import {
6 Accordion,
7 AccordionContent,
8 AccordionItem,
9 AccordionTrigger,
10 Button,
11 Checkbox,
12 cn,
13 Form,
14 FormControl,
15 FormField,
16 Input,
17 Label,
18 Separator,
19 Sheet,
20 SheetContent,
21 SheetDescription,
22 SheetFooter,
23 SheetHeader,
24 SheetSection,
25 SheetTitle,
26 Switch,
27 Textarea,
28} from 'ui'
29import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
30import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
31import {
32 getKeyValueFieldArrayValidationIssues,
33 stripEmptyKeyValueFieldArrayRows,
34} from 'ui-patterns/form/KeyValueFieldArray/validation'
35import * as z from 'zod'
36
37import type {
38 UpsertWebhookEndpointInput,
39 WebhookEndpoint,
40 WebhookScope,
41} from './PlatformWebhooks.types'
42import { generateWebhookEndpointName } from './PlatformWebhooks.utils'
43import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
44import { InlineLink } from '@/components/ui/InlineLink'
45import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
46import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
47
48const endpointFormSchema = z
49 .object({
50 name: z.string().trim().max(64, 'Name cannot exceed 64 characters'),
51 url: httpEndpointUrlSchema({
52 requiredMessage: 'Please provide a URL',
53 invalidMessage: 'Please provide a valid URL',
54 prefixMessage: 'Please prefix your URL with http:// or https://',
55 }),
56 description: z.string().trim().max(512, 'Description cannot exceed 512 characters'),
57 enabled: z.boolean().default(true),
58 subscribeAll: z.boolean().default(false),
59 eventTypes: z.array(z.string()).default([]),
60 customHeaders: z
61 .array(
62 z.object({
63 key: z.string().trim(),
64 value: z.string().trim(),
65 })
66 )
67 .default([]),
68 })
69 .superRefine((data, ctx) => {
70 if (!data.subscribeAll && data.eventTypes.length === 0) {
71 ctx.addIssue({
72 code: z.ZodIssueCode.custom,
73 message: 'Select at least one event type',
74 path: ['eventTypes'],
75 })
76 }
77
78 getKeyValueFieldArrayValidationIssues({
79 rows: data.customHeaders,
80 keyFieldName: 'key',
81 valueFieldName: 'value',
82 keyRequiredMessage: 'Header name is required',
83 valueRequiredMessage: 'Header value is required',
84 }).forEach((issue) => {
85 ctx.addIssue({
86 code: z.ZodIssueCode.custom,
87 message: issue.message,
88 path: ['customHeaders', ...issue.path],
89 })
90 })
91 })
92
93export type EndpointFormValues = z.infer<typeof endpointFormSchema>
94
95const toEventTypes = (values: EndpointFormValues) =>
96 values.subscribeAll ? ['*'] : values.eventTypes
97
98type EventTypeGroup = {
99 id: string
100 label: string
101 eventTypes: string[]
102}
103
104const buildEventTypeGroups = (scope: WebhookScope, eventTypes: string[]): EventTypeGroup[] => {
105 if (scope === 'project') {
106 return [{ id: 'project', label: 'Project events', eventTypes }]
107 }
108
109 const organizationEvents = eventTypes.filter((eventType) => eventType.startsWith('organization.'))
110 const projectEvents = eventTypes.filter((eventType) => eventType.startsWith('project.'))
111 const ungroupedEvents = eventTypes.filter(
112 (eventType) => !eventType.startsWith('organization.') && !eventType.startsWith('project.')
113 )
114
115 return [
116 { id: 'organization', label: 'Organization events', eventTypes: organizationEvents },
117 { id: 'project', label: 'Project events', eventTypes: projectEvents },
118 { id: 'other', label: 'Other events', eventTypes: ungroupedEvents },
119 ].filter((group) => group.eventTypes.length > 0)
120}
121
122const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => {
123 if (checked) return [...new Set([...selectedEventTypes, eventType])]
124 return selectedEventTypes.filter((value) => value !== eventType)
125}
126
127const toggleEventTypeGroup = (
128 selectedEventTypes: string[],
129 groupedEventTypes: string[],
130 checked: boolean
131) => {
132 if (checked) return [...new Set([...selectedEventTypes, ...groupedEventTypes])]
133 return selectedEventTypes.filter((value) => !groupedEventTypes.includes(value))
134}
135
136const toControlId = (prefix: string, value: string) =>
137 `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}`
138
139export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({
140 name: values.name,
141 url: values.url,
142 description: values.description,
143 enabled: values.enabled,
144 eventTypes: toEventTypes(values),
145 customHeaders: stripEmptyKeyValueFieldArrayRows({
146 rows: values.customHeaders,
147 keyFieldName: 'key',
148 valueFieldName: 'value',
149 }),
150})
151
152interface EndpointSheetProps {
153 visible: boolean
154 mode: 'create' | 'edit'
155 scope: WebhookScope
156 orgSlug?: string
157 endpoint?: WebhookEndpoint
158 enabledOverride?: boolean | null
159 eventTypes: string[]
160 onClose: () => void
161 onSubmit: (values: EndpointFormValues) => void
162}
163
164export const PlatformWebhooksEndpointSheet = ({
165 visible,
166 mode,
167 scope,
168 orgSlug,
169 endpoint,
170 enabledOverride,
171 eventTypes,
172 onClose,
173 onSubmit,
174}: EndpointSheetProps) => {
175 const form = useForm<EndpointFormValues>({
176 resolver: zodResolver(endpointFormSchema as any),
177 defaultValues: {
178 name: generateWebhookEndpointName(),
179 url: '',
180 description: '',
181 enabled: true,
182 subscribeAll: false,
183 eventTypes: [],
184 customHeaders: [],
185 },
186 })
187 const isDirty = form.formState.isDirty
188 const {
189 confirmOnClose,
190 handleOpenChange,
191 modalProps: discardChangesModalProps,
192 } = useConfirmOnClose({
193 checkIsDirty: () => isDirty,
194 onClose,
195 })
196
197 const subscribeAll = form.watch('subscribeAll')
198 const selectedEventTypes = form.watch('eventTypes')
199 const groupedEventTypes = useMemo(
200 () => buildEventTypeGroups(scope, eventTypes),
201 [scope, eventTypes]
202 )
203 const [openEventGroups, setOpenEventGroups] = useState<string[]>([])
204
205 useEffect(() => {
206 if (!visible) return
207
208 if (!endpoint) {
209 form.reset({
210 name: generateWebhookEndpointName(),
211 url: '',
212 description: '',
213 enabled: true,
214 subscribeAll: false,
215 eventTypes: [],
216 customHeaders: [],
217 })
218 return
219 }
220
221 form.reset({
222 name: endpoint.name,
223 url: endpoint.url,
224 description: endpoint.description,
225 enabled: enabledOverride ?? endpoint.enabled,
226 subscribeAll: endpoint.eventTypes.includes('*'),
227 eventTypes: endpoint.eventTypes.includes('*') ? eventTypes : endpoint.eventTypes,
228 customHeaders: endpoint.customHeaders.map((header) => ({
229 key: header.key,
230 value: header.value,
231 })),
232 })
233 }, [enabledOverride, endpoint, eventTypes, form, visible])
234
235 useEffect(() => {
236 if (!visible) return
237 setOpenEventGroups(groupedEventTypes.map((group) => group.id))
238 }, [groupedEventTypes, visible])
239
240 useEffect(() => {
241 if (!visible) return
242 const allSelected =
243 eventTypes.length > 0 &&
244 eventTypes.every((eventType) => selectedEventTypes.includes(eventType))
245
246 if (subscribeAll !== allSelected) {
247 form.setValue('subscribeAll', allSelected, {
248 shouldDirty: true,
249 shouldValidate: true,
250 })
251 }
252 }, [eventTypes, form, selectedEventTypes, subscribeAll, visible])
253
254 return (
255 <Sheet open={visible} onOpenChange={handleOpenChange}>
256 <SheetContent showClose={false} size="default" className="flex flex-col gap-0">
257 <SheetHeader>
258 <SheetTitle>{mode === 'create' ? 'Create endpoint' : 'Edit endpoint'}</SheetTitle>
259 <SheetDescription className="sr-only">
260 {mode === 'create'
261 ? 'Create a webhook endpoint by setting a name, URL, and event subscriptions.'
262 : 'Edit this webhook endpoint name, URL, and event subscriptions.'}
263 </SheetDescription>
264 </SheetHeader>
265 <Separator />
266 <SheetSection className="overflow-auto grow px-0 py-0">
267 <Form {...form}>
268 <form
269 id="platform-webhook-endpoint-form"
270 className="space-y-5 py-5"
271 onSubmit={form.handleSubmit(onSubmit)}
272 >
273 <div className="px-5 space-y-5">
274 <FormField
275 control={form.control}
276 name="name"
277 render={({ field }) => (
278 <FormItemLayout
279 label={
280 <>
281 Name
282 {/* Technically optional but encourage, so no (optional) label */}
283 </>
284 }
285 layout="vertical"
286 className="gap-1"
287 >
288 <FormControl>
289 <Input {...field} placeholder="winged-envelope" maxLength={64} />
290 </FormControl>
291 </FormItemLayout>
292 )}
293 />
294
295 <FormField
296 control={form.control}
297 name="url"
298 render={({ field }) => (
299 <FormItemLayout label="Endpoint URL" layout="vertical" className="gap-1">
300 <FormControl>
301 <Input {...field} placeholder="https://api.example.com/webhooks/briven" />
302 </FormControl>
303 </FormItemLayout>
304 )}
305 />
306
307 <FormField
308 control={form.control}
309 name="description"
310 render={({ field }) => (
311 <FormItemLayout
312 label={
313 <>
314 Description <span className="text-foreground-muted">(optional)</span>
315 </>
316 }
317 layout="vertical"
318 className="gap-1"
319 >
320 <FormControl>
321 <Textarea
322 {...field}
323 rows={4}
324 placeholder="Optional description for this endpoint"
325 className="resize-none"
326 />
327 </FormControl>
328 </FormItemLayout>
329 )}
330 />
331
332 {mode === 'edit' && (
333 <FormField
334 control={form.control}
335 name="enabled"
336 render={({ field }) => {
337 const enabledId = 'enabled-endpoint'
338 return (
339 <div className="rounded-md border bg-surface-100">
340 <Label
341 htmlFor={enabledId}
342 className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3"
343 >
344 <div className="space-y-0.5">
345 <p className="text-sm text-foreground">Enable endpoint</p>
346 <p className="text-sm text-foreground-lighter">
347 Disabled endpoints won’t receive deliveries
348 </p>
349 </div>
350 <FormControl>
351 <Switch
352 id={enabledId}
353 checked={field.value}
354 onCheckedChange={field.onChange}
355 />
356 </FormControl>
357 </Label>
358 </div>
359 )
360 }}
361 />
362 )}
363 </div>
364
365 <Separator />
366
367 <div className="px-5 space-y-3">
368 <FormField
369 control={form.control}
370 name="eventTypes"
371 render={({ field, fieldState }) => {
372 const selectedTypes = field.value ?? []
373 const hasEventTypeError = !!fieldState.error
374
375 return (
376 <FormItemLayout
377 label="Event types"
378 description={
379 scope === 'organization' ? (
380 <>
381 Project events are triggered when any project in this organization
382 matches the event type. Add a{' '}
383 <InlineLink href="/project/_/settings/webhooks">
384 project endpoint
385 </InlineLink>{' '}
386 to listen to events on an individual project only.
387 </>
388 ) : (
389 <>
390 Project events are triggered for this project only. Add an{' '}
391 <InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
392 organization endpoint
393 </InlineLink>{' '}
394 to listen to events from any project in your organization.
395 </>
396 )
397 }
398 layout="vertical"
399 className="gap-2"
400 >
401 <FormField
402 control={form.control}
403 name="subscribeAll"
404 render={({ field }) => {
405 const subscribeAllId = 'subscribe-all-events'
406 return (
407 <div className="rounded-md border bg-surface-100 overflow-hidden">
408 <Label
409 htmlFor={subscribeAllId}
410 className={cn(
411 'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
412 field.value ? 'bg-surface-100' : 'bg-surface-200'
413 )}
414 >
415 <FormControl>
416 <Checkbox
417 id={subscribeAllId}
418 checked={field.value}
419 onCheckedChange={(checked) => {
420 const nextValue = Boolean(checked)
421 field.onChange(nextValue)
422
423 if (nextValue) {
424 form.setValue('eventTypes', eventTypes, {
425 shouldDirty: true,
426 shouldValidate: true,
427 })
428 return
429 }
430
431 form.setValue('eventTypes', [], {
432 shouldDirty: true,
433 shouldValidate: true,
434 })
435 }}
436 />
437 </FormControl>
438 <span className="text-sm text-foreground">
439 Subscribe to all events{' '}
440 <code className="text-code-inline">(*)</code>
441 </span>
442 </Label>
443 </div>
444 )
445 }}
446 />
447
448 <FormControl>
449 <Accordion
450 type="multiple"
451 value={openEventGroups}
452 onValueChange={setOpenEventGroups}
453 className="mt-2 space-y-2"
454 >
455 {groupedEventTypes.map((group) => {
456 const selectedInGroup = group.eventTypes.filter((eventType) =>
457 selectedTypes.includes(eventType)
458 )
459 const allSelected = selectedInGroup.length === group.eventTypes.length
460 const isGroupOpen = openEventGroups.includes(group.id)
461
462 return (
463 <AccordionItem
464 key={group.id}
465 value={group.id}
466 className={cn(
467 'overflow-hidden rounded-md border',
468 hasEventTypeError && 'border-destructive-400'
469 )}
470 >
471 <AccordionTrigger
472 hideIcon
473 className="group px-4 py-3 hover:no-underline"
474 >
475 <div className="flex w-full items-center justify-between gap-3">
476 <div className="flex items-center gap-2">
477 <p className="text-sm text-foreground-light">
478 {group.label}
479 </p>
480 {selectedInGroup.length > 0 && (
481 <span className="text-xs text-foreground-muted">
482 {selectedInGroup.length}
483 </span>
484 )}
485 </div>
486 <div className="flex items-center gap-3">
487 {isGroupOpen && group.eventTypes.length > 1 && (
488 <span
489 className="text-xs text-foreground-muted hover:text-foreground"
490 onClick={(event) => {
491 event.preventDefault()
492 event.stopPropagation()
493 field.onChange(
494 toggleEventTypeGroup(
495 selectedTypes,
496 group.eventTypes,
497 !allSelected
498 )
499 )
500 }}
501 >
502 {allSelected ? 'Clear all' : 'Select all'}
503 </span>
504 )}
505 <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-open:rotate-180" />
506 </div>
507 </div>
508 </AccordionTrigger>
509 <AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
510 <div className="divide-y border-t">
511 {group.eventTypes.map((eventType) => {
512 const checked = selectedTypes.includes(eventType)
513 const eventTypeId = toControlId('event-type', eventType)
514
515 return (
516 <Label
517 key={eventType}
518 htmlFor={eventTypeId}
519 className={cn(
520 'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
521 checked && 'bg-surface-100'
522 )}
523 >
524 <Checkbox
525 id={eventTypeId}
526 checked={checked}
527 onCheckedChange={(next) => {
528 field.onChange(
529 toggleEventType(
530 selectedTypes,
531 eventType,
532 Boolean(next)
533 )
534 )
535 }}
536 />
537 <code className="text-code-inline">{eventType}</code>
538 </Label>
539 )
540 })}
541 </div>
542 </AccordionContent>
543 </AccordionItem>
544 )
545 })}
546 </Accordion>
547 </FormControl>
548 </FormItemLayout>
549 )
550 }}
551 />
552 </div>
553
554 <Separator />
555
556 <div className="px-5 space-y-3">
557 <FormItemLayout
558 label={
559 <>
560 Custom headers <span className="text-foreground-muted">(optional)</span>
561 </>
562 }
563 description="Optional HTTP headers sent with every delivery."
564 layout="vertical"
565 className="gap-3"
566 >
567 <KeyValueFieldArray
568 control={form.control}
569 name="customHeaders"
570 keyFieldName="key"
571 valueFieldName="value"
572 createEmptyRow={() => ({ key: '', value: '' })}
573 keyPlaceholder="Header name"
574 valuePlaceholder="Header value"
575 addLabel="Add header"
576 />
577 </FormItemLayout>
578 </div>
579 </form>
580 </Form>
581 </SheetSection>
582 <SheetFooter>
583 <Button type="default" onClick={confirmOnClose}>
584 Cancel
585 </Button>
586 <Button form="platform-webhook-endpoint-form" htmlType="submit">
587 {mode === 'create' ? 'Create endpoint' : 'Save changes'}
588 </Button>
589 </SheetFooter>
590 </SheetContent>
591 <DiscardChangesConfirmationDialog {...discardChangesModalProps} />
592 </Sheet>
593 )
594}