| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- 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<typeof endpointFormSchema>
- 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<EndpointFormValues>({
- 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<string[]>([])
- 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 (
- <Sheet open={visible} onOpenChange={handleOpenChange}>
- <SheetContent showClose={false} size="default" className="flex flex-col gap-0">
- <SheetHeader>
- <SheetTitle>{mode === 'create' ? 'Create endpoint' : 'Edit endpoint'}</SheetTitle>
- <SheetDescription className="sr-only">
- {mode === 'create'
- ? 'Create a webhook endpoint by setting a name, URL, and event subscriptions.'
- : 'Edit this webhook endpoint name, URL, and event subscriptions.'}
- </SheetDescription>
- </SheetHeader>
- <Separator />
- <SheetSection className="overflow-auto grow px-0 py-0">
- <Form {...form}>
- <form
- id="platform-webhook-endpoint-form"
- className="space-y-5 py-5"
- onSubmit={form.handleSubmit(onSubmit)}
- >
- <div className="px-5 space-y-5">
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItemLayout
- label={
- <>
- Name
- {/* Technically optional but encourage, so no (optional) label */}
- </>
- }
- layout="vertical"
- className="gap-1"
- >
- <FormControl>
- <Input {...field} placeholder="winged-envelope" maxLength={64} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="url"
- render={({ field }) => (
- <FormItemLayout label="Endpoint URL" layout="vertical" className="gap-1">
- <FormControl>
- <Input {...field} placeholder="https://api.example.com/webhooks/briven" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItemLayout
- label={
- <>
- Description <span className="text-foreground-muted">(optional)</span>
- </>
- }
- layout="vertical"
- className="gap-1"
- >
- <FormControl>
- <Textarea
- {...field}
- rows={4}
- placeholder="Optional description for this endpoint"
- className="resize-none"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- {mode === 'edit' && (
- <FormField
- control={form.control}
- name="enabled"
- render={({ field }) => {
- const enabledId = 'enabled-endpoint'
- return (
- <div className="rounded-md border bg-surface-100">
- <Label
- htmlFor={enabledId}
- className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3"
- >
- <div className="space-y-0.5">
- <p className="text-sm text-foreground">Enable endpoint</p>
- <p className="text-sm text-foreground-lighter">
- Disabled endpoints won’t receive deliveries
- </p>
- </div>
- <FormControl>
- <Switch
- id={enabledId}
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </Label>
- </div>
- )
- }}
- />
- )}
- </div>
- <Separator />
- <div className="px-5 space-y-3">
- <FormField
- control={form.control}
- name="eventTypes"
- render={({ field, fieldState }) => {
- const selectedTypes = field.value ?? []
- const hasEventTypeError = !!fieldState.error
- return (
- <FormItemLayout
- label="Event types"
- description={
- scope === 'organization' ? (
- <>
- Project events are triggered when any project in this organization
- matches the event type. Add a{' '}
- <InlineLink href="/project/_/settings/webhooks">
- project endpoint
- </InlineLink>{' '}
- to listen to events on an individual project only.
- </>
- ) : (
- <>
- Project events are triggered for this project only. Add an{' '}
- <InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
- organization endpoint
- </InlineLink>{' '}
- to listen to events from any project in your organization.
- </>
- )
- }
- layout="vertical"
- className="gap-2"
- >
- <FormField
- control={form.control}
- name="subscribeAll"
- render={({ field }) => {
- const subscribeAllId = 'subscribe-all-events'
- return (
- <div className="rounded-md border bg-surface-100 overflow-hidden">
- <Label
- htmlFor={subscribeAllId}
- className={cn(
- 'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
- field.value ? 'bg-surface-100' : 'bg-surface-200'
- )}
- >
- <FormControl>
- <Checkbox
- id={subscribeAllId}
- checked={field.value}
- onCheckedChange={(checked) => {
- const nextValue = Boolean(checked)
- field.onChange(nextValue)
- if (nextValue) {
- form.setValue('eventTypes', eventTypes, {
- shouldDirty: true,
- shouldValidate: true,
- })
- return
- }
- form.setValue('eventTypes', [], {
- shouldDirty: true,
- shouldValidate: true,
- })
- }}
- />
- </FormControl>
- <span className="text-sm text-foreground">
- Subscribe to all events{' '}
- <code className="text-code-inline">(*)</code>
- </span>
- </Label>
- </div>
- )
- }}
- />
- <FormControl>
- <Accordion
- type="multiple"
- value={openEventGroups}
- onValueChange={setOpenEventGroups}
- className="mt-2 space-y-2"
- >
- {groupedEventTypes.map((group) => {
- const selectedInGroup = group.eventTypes.filter((eventType) =>
- selectedTypes.includes(eventType)
- )
- const allSelected = selectedInGroup.length === group.eventTypes.length
- const isGroupOpen = openEventGroups.includes(group.id)
- return (
- <AccordionItem
- key={group.id}
- value={group.id}
- className={cn(
- 'overflow-hidden rounded-md border',
- hasEventTypeError && 'border-destructive-400'
- )}
- >
- <AccordionTrigger
- hideIcon
- className="group px-4 py-3 hover:no-underline"
- >
- <div className="flex w-full items-center justify-between gap-3">
- <div className="flex items-center gap-2">
- <p className="text-sm text-foreground-light">
- {group.label}
- </p>
- {selectedInGroup.length > 0 && (
- <span className="text-xs text-foreground-muted">
- {selectedInGroup.length}
- </span>
- )}
- </div>
- <div className="flex items-center gap-3">
- {isGroupOpen && group.eventTypes.length > 1 && (
- <span
- className="text-xs text-foreground-muted hover:text-foreground"
- onClick={(event) => {
- event.preventDefault()
- event.stopPropagation()
- field.onChange(
- toggleEventTypeGroup(
- selectedTypes,
- group.eventTypes,
- !allSelected
- )
- )
- }}
- >
- {allSelected ? 'Clear all' : 'Select all'}
- </span>
- )}
- <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-open:rotate-180" />
- </div>
- </div>
- </AccordionTrigger>
- <AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
- <div className="divide-y border-t">
- {group.eventTypes.map((eventType) => {
- const checked = selectedTypes.includes(eventType)
- const eventTypeId = toControlId('event-type', eventType)
- return (
- <Label
- key={eventType}
- htmlFor={eventTypeId}
- className={cn(
- 'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
- checked && 'bg-surface-100'
- )}
- >
- <Checkbox
- id={eventTypeId}
- checked={checked}
- onCheckedChange={(next) => {
- field.onChange(
- toggleEventType(
- selectedTypes,
- eventType,
- Boolean(next)
- )
- )
- }}
- />
- <code className="text-code-inline">{eventType}</code>
- </Label>
- )
- })}
- </div>
- </AccordionContent>
- </AccordionItem>
- )
- })}
- </Accordion>
- </FormControl>
- </FormItemLayout>
- )
- }}
- />
- </div>
- <Separator />
- <div className="px-5 space-y-3">
- <FormItemLayout
- label={
- <>
- Custom headers <span className="text-foreground-muted">(optional)</span>
- </>
- }
- description="Optional HTTP headers sent with every delivery."
- layout="vertical"
- className="gap-3"
- >
- <KeyValueFieldArray
- control={form.control}
- name="customHeaders"
- keyFieldName="key"
- valueFieldName="value"
- createEmptyRow={() => ({ key: '', value: '' })}
- keyPlaceholder="Header name"
- valuePlaceholder="Header value"
- addLabel="Add header"
- />
- </FormItemLayout>
- </div>
- </form>
- </Form>
- </SheetSection>
- <SheetFooter>
- <Button type="default" onClick={confirmOnClose}>
- Cancel
- </Button>
- <Button form="platform-webhook-endpoint-form" htmlType="submit">
- {mode === 'create' ? 'Create endpoint' : 'Save changes'}
- </Button>
- </SheetFooter>
- </SheetContent>
- <DiscardChangesConfirmationDialog {...discardChangesModalProps} />
- </Sheet>
- )
- }
|