PlatformWebhooksEndpointSheet.tsx594 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { ChevronDown } from 'lucide-react' |
| 3 | import { useEffect, useMemo, useState } from 'react' |
| 4 | import { useForm } from 'react-hook-form' |
| 5 | import { |
| 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' |
| 29 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 30 | import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' |
| 31 | import { |
| 32 | getKeyValueFieldArrayValidationIssues, |
| 33 | stripEmptyKeyValueFieldArrayRows, |
| 34 | } from 'ui-patterns/form/KeyValueFieldArray/validation' |
| 35 | import * as z from 'zod' |
| 36 | |
| 37 | import type { |
| 38 | UpsertWebhookEndpointInput, |
| 39 | WebhookEndpoint, |
| 40 | WebhookScope, |
| 41 | } from './PlatformWebhooks.types' |
| 42 | import { generateWebhookEndpointName } from './PlatformWebhooks.utils' |
| 43 | import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' |
| 44 | import { InlineLink } from '@/components/ui/InlineLink' |
| 45 | import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' |
| 46 | import { httpEndpointUrlSchema } from '@/lib/validation/http-url' |
| 47 | |
| 48 | const 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 | |
| 93 | export type EndpointFormValues = z.infer<typeof endpointFormSchema> |
| 94 | |
| 95 | const toEventTypes = (values: EndpointFormValues) => |
| 96 | values.subscribeAll ? ['*'] : values.eventTypes |
| 97 | |
| 98 | type EventTypeGroup = { |
| 99 | id: string |
| 100 | label: string |
| 101 | eventTypes: string[] |
| 102 | } |
| 103 | |
| 104 | const 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 | |
| 122 | const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => { |
| 123 | if (checked) return [...new Set([...selectedEventTypes, eventType])] |
| 124 | return selectedEventTypes.filter((value) => value !== eventType) |
| 125 | } |
| 126 | |
| 127 | const 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 | |
| 136 | const toControlId = (prefix: string, value: string) => |
| 137 | `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}` |
| 138 | |
| 139 | export 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 | |
| 152 | interface 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 | |
| 164 | export 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 | } |