PlatformWebhooksEndpointSheet.tsx 23 KB

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