Files
Joshen Lim fcfb0f0222 Refactor all usage of form.watch to either useWatch or subscribe (#48436)
## Context

Replaces all usage of `form.watch()` to use `useWatch` instead + follows
the "name what you watch" convention as specified in the react-hook-form
skills.

There's also a small refactor in `SmtpForm.tsx` which removes the
unnecessary use of a `useState` to track if SMTP is enabled or not

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Updated many Studio forms to watch specific fields more precisely,
improving live UI updates for previews, warnings, conditional sections,
and validation messages.
* Enhanced responsiveness across settings, authentication, billing,
storage, integrations, and support flows while keeping save/update
behavior the same.
* **Refined Experiences**
* Improved the analytics table creation flow with tighter, enum-based
column type validation and structured, type-specific column options.
* **Preserved Behavior**
* Maintained existing permission checks, submission flows, and
account-management workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 11:45:40 +08:00

603 lines
24 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 variant="default" onClick={confirmOnClose}>
Cancel
</Button>
<Shortcut
id={SHORTCUT_IDS.ACTION_BAR_SAVE}
label={mode === 'create' ? 'Create endpoint' : 'Save changes'}
onTrigger={() => form.handleSubmit(onSubmit)()}
>
<Button form="platform-webhook-endpoint-form" type="submit">
{mode === 'create' ? 'Create endpoint' : 'Save changes'}
</Button>
</Shortcut>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...discardChangesModalProps} />
</Sheet>
)
}