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.briven.${restUrlTld}/`) || url.includes(`https://${ref}.briven.${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 = {} 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 = {} 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({ resolver: zodResolver(FormSchema as any), 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] ?? '', }) ? 'briven_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] ?? '', }) ? 'briven_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 = 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 ) const parameters = values.httpParameters .filter((param) => param.name && param.value) .reduce( (a, b) => { a[b.name] = b.value return a }, {} as Record ) // 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: 'briven_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 ( <> Update webhook {selectedHook.name} ) } className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out" onConfirm={() => {}} onCancel={confirmOnClose} customFooter={
} >
) }