Files
supabase/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

601 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, useWatch } 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 { Shortcut } from '@/components/ui/Shortcut'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
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),
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 = useWatch({ control: form.control, name: 'subscribeAll' })
const selectedEventTypes = useWatch({ control: form.control, name: '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/supabase" />
</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 onClick={confirmOnClose}>Cancel</Button>
<Shortcut
id={SHORTCUT_IDS.ACTION_BAR_SAVE}
label={mode === 'create' ? 'Create endpoint' : 'Save changes'}
onTrigger={() => form.handleSubmit(onSubmit)()}
>
<Button variant="primary" form="platform-webhook-endpoint-form" type="submit">
{mode === 'create' ? 'Create endpoint' : 'Save changes'}
</Button>
</Shortcut>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...discardChangesModalProps} />
</Sheet>
)
}