Files
supabase/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
T
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

330 lines
11 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { keyword } from '@supabase/pg-meta'
import type { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta'
import { useQueryClient } from '@tanstack/react-query'
import { useFlag, useParams } from 'common'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { Button, Form, SidePanel } from 'ui'
import { FormSchema, WebhookFormValues } from './EditHookPanel.constants'
import { FormContents } from './FormContents'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-transaction-mutation'
import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query'
import {
PG_META_SCOPED_INTROSPECTION_FLAG,
tableEditorQueryOptions,
} from '@/data/table-editor/table-editor-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
import { uuidv4 } from '@/lib/helpers'
export type HTTPArgument = { id: string; name: string; value: string }
const FORM_ID = 'edit-hook-panel-form'
const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => {
if (typeof selectedHook === 'undefined') {
return [{ id: uuidv4(), name: 'Content-type', value: 'application/json' }]
}
const [, , headers] = selectedHook.function_args
let parsedHeaders: Record<string, string> = {}
try {
parsedHeaders = JSON.parse(headers.replace(/\\"/g, '"'))
} catch (e) {
parsedHeaders = {}
}
return Object.entries(parsedHeaders).map(([name, value]) => ({
id: uuidv4(),
name,
value,
}))
}
const parseParameters = (selectedHook?: PGTrigger): HTTPArgument[] => {
if (typeof selectedHook === 'undefined') {
return [{ id: uuidv4(), name: '', value: '' }]
}
const [, , , parameters] = selectedHook.function_args
let parsedParameters: Record<string, string> = {}
try {
parsedParameters = JSON.parse(parameters.replace(/\\"/g, '"'))
} catch (e) {
parsedParameters = {}
}
return Object.entries(parsedParameters).map(([name, value]) => ({
id: uuidv4(),
name,
value,
}))
}
export const EditHookPanel = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isLoadingTable, setIsLoadingTable] = useState(false)
const scoped = !!useFlag(PG_META_SCOPED_INTROSPECTION_FLAG)
const { data: hooks = [], isSuccess } = useDatabaseHooksQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [showCreateHookForm, setShowCreateHookForm] = useQueryState(
'new',
parseAsBoolean.withDefault(false)
)
const [selectedHookIdToEdit, setSelectedHookIdToEdit] = useQueryState(
'edit',
parseAsString.withDefault('')
)
const selectedHook = hooks.find((hook) => hook.id.toString() === selectedHookIdToEdit)
// Webhook IDs aren't stable across edits because the update mutation drops and recreates the
// trigger, assigning a new ID. This causes a brief window where the old selectedHookIdToEdit
// no longer matches any hook, incorrectly triggering the "Webhook not found" toast. Since this
// is an edge case, we use an ad-hoc ref to suppress the toast when the panel is closing rather
// than a more involved solution
const isClosingRef = useRef(false)
const visible = showCreateHookForm || !!selectedHook
const onClose = () => {
isClosingRef.current = true
setShowCreateHookForm(false)
setSelectedHookIdToEdit(null)
}
const { mutate: createDatabaseTrigger, isPending: isCreating } = useDatabaseTriggerCreateMutation(
{
onSuccess: (_, variables) => {
toast.success(`Successfully created new webhook "${variables.payload.name}"`)
onClose()
},
onError: (error) => {
toast.error(`Failed to create webhook: ${error.message}`)
},
}
)
const { mutate: updateDatabaseTrigger, isPending: isUpdating } = useDatabaseTriggerUpdateMutation(
{
onSuccess: (res) => {
toast.success(`Successfully updated webhook "${res.name}"`)
onClose()
},
onError: (error) => {
toast.error(`Failed to update webhook: ${error.message}`)
},
}
)
const isSubmitting = isCreating || isUpdating || isLoadingTable
const restUrl = project?.restUrl
const form = useForm<WebhookFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id?.toString() ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST',
function_type: isEdgeFunctionUrl(selectedHook?.function_args?.[0] ?? '', ref ?? '', restUrl)
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
events: selectedHook?.events ?? [],
httpHeaders: parseHeaders(selectedHook),
httpParameters: parseParameters(selectedHook),
},
})
useEffect(() => {
if (isSuccess && !!selectedHookIdToEdit && !selectedHook && !isClosingRef.current) {
toast('Webhook not found')
setSelectedHookIdToEdit(null)
}
}, [isSuccess, selectedHook, selectedHookIdToEdit, setSelectedHookIdToEdit])
// Reset the closing ref when the panel fully closes
useEffect(() => {
if (!visible) {
isClosingRef.current = false
}
}, [visible])
// Reset form when panel opens with new selectedHook
useEffect(() => {
if (visible) {
form.reset({
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id?.toString() ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST',
function_type: isEdgeFunctionUrl(selectedHook?.function_args?.[0] ?? '', ref ?? '', restUrl)
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
events: selectedHook?.events ?? [],
httpHeaders: parseHeaders(selectedHook),
httpParameters: parseParameters(selectedHook),
})
}
}, [visible, selectedHook, ref, restUrl, form])
const queryClient = useQueryClient()
const onSubmit: SubmitHandler<WebhookFormValues> = async (values) => {
if (!project?.ref) {
return console.error('Project ref is required')
}
try {
setIsLoadingTable(true)
const selectedTable = await queryClient.fetchQuery(
tableEditorQueryOptions({
id: Number(values.table_id),
projectRef: project?.ref,
connectionString: project?.connectionString,
scoped,
})
)
if (!selectedTable) {
return toast.error('Unable to find selected table')
}
const headers = values.httpHeaders
.filter((header) => header.name && header.value)
.reduce(
(a, b) => {
a[b.name] = b.value
return a
},
{} as Record<string, string>
)
const parameters = values.httpParameters
.filter((param) => param.name && param.value)
.reduce(
(a, b) => {
a[b.name] = b.value
return a
},
{} as Record<string, string>
)
// replacer function with JSON.stringify to handle quotes properly
const stringifiedParameters = JSON.stringify(parameters, (_key, value) => {
if (typeof value === 'string') {
// Return the raw string without any additional escaping
return value
}
return value
})
const payload: PGTriggerCreate = {
events: values.events,
activation: 'AFTER',
orientation: 'ROW',
name: values.name,
table: selectedTable.name,
schema: selectedTable.schema,
function_name: 'http_request',
function_schema: 'supabase_functions',
function_args: [
values.http_url,
values.http_method,
JSON.stringify(headers),
stringifiedParameters,
values.timeout_ms.toString(),
],
}
if (selectedHook === undefined) {
createDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
payload,
})
} else {
updateDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
originalTrigger: selectedHook,
updatedTrigger: {
...payload,
enabled_mode: 'ORIGIN',
events: payload.events.map(keyword),
},
})
}
} catch (error) {
console.error('Failed to get table editor:', error)
toast.error('Failed to get table editor')
} finally {
setIsLoadingTable(false)
}
}
// This is intentionally kept outside of the useConfirmOnClose hook to force RHF to update the isDirty state.
const isDirty = form.formState.isDirty
const { confirmOnClose, modalProps } = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose: () => onClose(),
})
return (
<>
<SidePanel
size="xlarge"
visible={visible}
header={
selectedHook === undefined ? (
'Create a new database webhook'
) : (
<>
Update webhook <code className="text-sm">{selectedHook.name}</code>
</>
)
}
className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
onConfirm={() => {}}
onCancel={confirmOnClose}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-default px-3 py-4">
<Button size="tiny" type="button" onClick={confirmOnClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
size="tiny"
variant="primary"
type="submit"
form={FORM_ID}
disabled={isSubmitting}
loading={isSubmitting}
>
{selectedHook === undefined ? 'Create webhook' : 'Update webhook'}
</Button>
</div>
}
>
<Form {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<FormContents form={form} selectedHook={selectedHook} />
</form>
</Form>
</SidePanel>
<DiscardChangesConfirmationDialog {...modalProps} />
</>
)
}