Files
supabase/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
Gildas Garcia 96d43099bb chore: refactor Button API so that it can be used a standard button (#46880)
## Problem

Our `<Button>` component breaks the default `button` contract by
redefining the `type` prop to set its variant (`primary`, `default`,
etc) instead of the button type (`submit`, `button`, etc).
This is confusing and forces to write more code when using it with
shadcn components that expect/inject the standard button props.

## Solution

- rename the `type` prop to `variant`
- rename the `htmlType` prop to `type`
- propagate the changes where necessary
- format code

## How to test

As this is just prop renaming, if it builds it's ok

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-06-16 23:59:58 +02:00

351 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 { 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 { tableEditorQueryOptions } from '@/data/table-editor/table-editor-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { uuidv4 } from '@/lib/helpers'
export type HTTPArgument = { id: string; name: string; value: string }
export const isEdgeFunction = ({
ref,
restUrlTld,
url,
}: {
ref?: string
restUrlTld?: string
url: string
}) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
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 { 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 restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
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: isEdgeFunction({
ref,
restUrlTld,
url: selectedHook?.function_args?.[0] ?? '',
})
? '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: isEdgeFunction({
ref,
restUrlTld,
url: selectedHook?.function_args?.[0] ?? '',
})
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
events: selectedHook?.events ?? [],
httpHeaders: parseHeaders(selectedHook),
httpParameters: parseParameters(selectedHook),
})
}
}, [visible, selectedHook, ref, restUrlTld, 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,
})
)
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"
variant="default"
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} />
</>
)
}