EditHookPanel.tsx350 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { keyword } from '@supabase/pg-meta' |
| 3 | import type { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta' |
| 4 | import { useQueryClient } from '@tanstack/react-query' |
| 5 | import { useParams } from 'common' |
| 6 | import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' |
| 7 | import { useEffect, useRef, useState } from 'react' |
| 8 | import { SubmitHandler, useForm } from 'react-hook-form' |
| 9 | import { toast } from 'sonner' |
| 10 | import { Button, Form, SidePanel } from 'ui' |
| 11 | |
| 12 | import { FormSchema, WebhookFormValues } from './EditHookPanel.constants' |
| 13 | import { FormContents } from './FormContents' |
| 14 | import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' |
| 15 | import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation' |
| 16 | import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-transaction-mutation' |
| 17 | import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query' |
| 18 | import { tableEditorQueryOptions } from '@/data/table-editor/table-editor-query' |
| 19 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 20 | import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' |
| 21 | import { uuidv4 } from '@/lib/helpers' |
| 22 | |
| 23 | export type HTTPArgument = { id: string; name: string; value: string } |
| 24 | |
| 25 | export const isEdgeFunction = ({ |
| 26 | ref, |
| 27 | restUrlTld, |
| 28 | url, |
| 29 | }: { |
| 30 | ref?: string |
| 31 | restUrlTld?: string |
| 32 | url: string |
| 33 | }) => |
| 34 | url.includes(`https://${ref}.functions.briven.${restUrlTld}/`) || |
| 35 | url.includes(`https://${ref}.briven.${restUrlTld}/functions/`) |
| 36 | |
| 37 | const FORM_ID = 'edit-hook-panel-form' |
| 38 | |
| 39 | const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => { |
| 40 | if (typeof selectedHook === 'undefined') { |
| 41 | return [{ id: uuidv4(), name: 'Content-type', value: 'application/json' }] |
| 42 | } |
| 43 | const [, , headers] = selectedHook.function_args |
| 44 | let parsedHeaders: Record<string, string> = {} |
| 45 | |
| 46 | try { |
| 47 | parsedHeaders = JSON.parse(headers.replace(/\\"/g, '"')) |
| 48 | } catch (e) { |
| 49 | parsedHeaders = {} |
| 50 | } |
| 51 | |
| 52 | return Object.entries(parsedHeaders).map(([name, value]) => ({ |
| 53 | id: uuidv4(), |
| 54 | name, |
| 55 | value, |
| 56 | })) |
| 57 | } |
| 58 | |
| 59 | const parseParameters = (selectedHook?: PGTrigger): HTTPArgument[] => { |
| 60 | if (typeof selectedHook === 'undefined') { |
| 61 | return [{ id: uuidv4(), name: '', value: '' }] |
| 62 | } |
| 63 | const [, , , parameters] = selectedHook.function_args |
| 64 | let parsedParameters: Record<string, string> = {} |
| 65 | |
| 66 | try { |
| 67 | parsedParameters = JSON.parse(parameters.replace(/\\"/g, '"')) |
| 68 | } catch (e) { |
| 69 | parsedParameters = {} |
| 70 | } |
| 71 | |
| 72 | return Object.entries(parsedParameters).map(([name, value]) => ({ |
| 73 | id: uuidv4(), |
| 74 | name, |
| 75 | value, |
| 76 | })) |
| 77 | } |
| 78 | |
| 79 | export const EditHookPanel = () => { |
| 80 | const { ref } = useParams() |
| 81 | const { data: project } = useSelectedProjectQuery() |
| 82 | const [isLoadingTable, setIsLoadingTable] = useState(false) |
| 83 | |
| 84 | const { data: hooks = [], isSuccess } = useDatabaseHooksQuery({ |
| 85 | projectRef: project?.ref, |
| 86 | connectionString: project?.connectionString, |
| 87 | }) |
| 88 | |
| 89 | const [showCreateHookForm, setShowCreateHookForm] = useQueryState( |
| 90 | 'new', |
| 91 | parseAsBoolean.withDefault(false) |
| 92 | ) |
| 93 | |
| 94 | const [selectedHookIdToEdit, setSelectedHookIdToEdit] = useQueryState( |
| 95 | 'edit', |
| 96 | parseAsString.withDefault('') |
| 97 | ) |
| 98 | const selectedHook = hooks.find((hook) => hook.id.toString() === selectedHookIdToEdit) |
| 99 | |
| 100 | // Webhook IDs aren't stable across edits because the update mutation drops and recreates the |
| 101 | // trigger, assigning a new ID. This causes a brief window where the old selectedHookIdToEdit |
| 102 | // no longer matches any hook, incorrectly triggering the "Webhook not found" toast. Since this |
| 103 | // is an edge case, we use an ad-hoc ref to suppress the toast when the panel is closing rather |
| 104 | // than a more involved solution |
| 105 | const isClosingRef = useRef(false) |
| 106 | |
| 107 | const visible = showCreateHookForm || !!selectedHook |
| 108 | |
| 109 | const onClose = () => { |
| 110 | isClosingRef.current = true |
| 111 | setShowCreateHookForm(false) |
| 112 | setSelectedHookIdToEdit(null) |
| 113 | } |
| 114 | |
| 115 | const { mutate: createDatabaseTrigger, isPending: isCreating } = useDatabaseTriggerCreateMutation( |
| 116 | { |
| 117 | onSuccess: (_, variables) => { |
| 118 | toast.success(`Successfully created new webhook "${variables.payload.name}"`) |
| 119 | onClose() |
| 120 | }, |
| 121 | onError: (error) => { |
| 122 | toast.error(`Failed to create webhook: ${error.message}`) |
| 123 | }, |
| 124 | } |
| 125 | ) |
| 126 | |
| 127 | const { mutate: updateDatabaseTrigger, isPending: isUpdating } = useDatabaseTriggerUpdateMutation( |
| 128 | { |
| 129 | onSuccess: (res) => { |
| 130 | toast.success(`Successfully updated webhook "${res.name}"`) |
| 131 | onClose() |
| 132 | }, |
| 133 | onError: (error) => { |
| 134 | toast.error(`Failed to update webhook: ${error.message}`) |
| 135 | }, |
| 136 | } |
| 137 | ) |
| 138 | |
| 139 | const isSubmitting = isCreating || isUpdating || isLoadingTable |
| 140 | |
| 141 | const restUrl = project?.restUrl |
| 142 | const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' |
| 143 | |
| 144 | const form = useForm<WebhookFormValues>({ |
| 145 | resolver: zodResolver(FormSchema as any), |
| 146 | defaultValues: { |
| 147 | name: selectedHook?.name ?? '', |
| 148 | table_id: selectedHook?.table_id?.toString() ?? '', |
| 149 | http_url: selectedHook?.function_args?.[0] ?? '', |
| 150 | http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST', |
| 151 | function_type: isEdgeFunction({ |
| 152 | ref, |
| 153 | restUrlTld, |
| 154 | url: selectedHook?.function_args?.[0] ?? '', |
| 155 | }) |
| 156 | ? 'briven_function' |
| 157 | : 'http_request', |
| 158 | timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), |
| 159 | events: selectedHook?.events ?? [], |
| 160 | httpHeaders: parseHeaders(selectedHook), |
| 161 | httpParameters: parseParameters(selectedHook), |
| 162 | }, |
| 163 | }) |
| 164 | |
| 165 | useEffect(() => { |
| 166 | if (isSuccess && !!selectedHookIdToEdit && !selectedHook && !isClosingRef.current) { |
| 167 | toast('Webhook not found') |
| 168 | setSelectedHookIdToEdit(null) |
| 169 | } |
| 170 | }, [isSuccess, selectedHook, selectedHookIdToEdit, setSelectedHookIdToEdit]) |
| 171 | |
| 172 | // Reset the closing ref when the panel fully closes |
| 173 | useEffect(() => { |
| 174 | if (!visible) { |
| 175 | isClosingRef.current = false |
| 176 | } |
| 177 | }, [visible]) |
| 178 | |
| 179 | // Reset form when panel opens with new selectedHook |
| 180 | useEffect(() => { |
| 181 | if (visible) { |
| 182 | form.reset({ |
| 183 | name: selectedHook?.name ?? '', |
| 184 | table_id: selectedHook?.table_id?.toString() ?? '', |
| 185 | http_url: selectedHook?.function_args?.[0] ?? '', |
| 186 | http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST', |
| 187 | function_type: isEdgeFunction({ |
| 188 | ref, |
| 189 | restUrlTld, |
| 190 | url: selectedHook?.function_args?.[0] ?? '', |
| 191 | }) |
| 192 | ? 'briven_function' |
| 193 | : 'http_request', |
| 194 | timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), |
| 195 | events: selectedHook?.events ?? [], |
| 196 | httpHeaders: parseHeaders(selectedHook), |
| 197 | httpParameters: parseParameters(selectedHook), |
| 198 | }) |
| 199 | } |
| 200 | }, [visible, selectedHook, ref, restUrlTld, form]) |
| 201 | |
| 202 | const queryClient = useQueryClient() |
| 203 | const onSubmit: SubmitHandler<WebhookFormValues> = async (values) => { |
| 204 | if (!project?.ref) { |
| 205 | return console.error('Project ref is required') |
| 206 | } |
| 207 | |
| 208 | try { |
| 209 | setIsLoadingTable(true) |
| 210 | const selectedTable = await queryClient.fetchQuery( |
| 211 | tableEditorQueryOptions({ |
| 212 | id: Number(values.table_id), |
| 213 | projectRef: project?.ref, |
| 214 | connectionString: project?.connectionString, |
| 215 | }) |
| 216 | ) |
| 217 | if (!selectedTable) { |
| 218 | return toast.error('Unable to find selected table') |
| 219 | } |
| 220 | |
| 221 | const headers = values.httpHeaders |
| 222 | .filter((header) => header.name && header.value) |
| 223 | .reduce( |
| 224 | (a, b) => { |
| 225 | a[b.name] = b.value |
| 226 | return a |
| 227 | }, |
| 228 | {} as Record<string, string> |
| 229 | ) |
| 230 | const parameters = values.httpParameters |
| 231 | .filter((param) => param.name && param.value) |
| 232 | .reduce( |
| 233 | (a, b) => { |
| 234 | a[b.name] = b.value |
| 235 | return a |
| 236 | }, |
| 237 | {} as Record<string, string> |
| 238 | ) |
| 239 | |
| 240 | // replacer function with JSON.stringify to handle quotes properly |
| 241 | const stringifiedParameters = JSON.stringify(parameters, (_key, value) => { |
| 242 | if (typeof value === 'string') { |
| 243 | // Return the raw string without any additional escaping |
| 244 | return value |
| 245 | } |
| 246 | return value |
| 247 | }) |
| 248 | |
| 249 | const payload: PGTriggerCreate = { |
| 250 | events: values.events, |
| 251 | activation: 'AFTER', |
| 252 | orientation: 'ROW', |
| 253 | name: values.name, |
| 254 | table: selectedTable.name, |
| 255 | schema: selectedTable.schema, |
| 256 | function_name: 'http_request', |
| 257 | function_schema: 'briven_functions', |
| 258 | function_args: [ |
| 259 | values.http_url, |
| 260 | values.http_method, |
| 261 | JSON.stringify(headers), |
| 262 | stringifiedParameters, |
| 263 | values.timeout_ms.toString(), |
| 264 | ], |
| 265 | } |
| 266 | |
| 267 | if (selectedHook === undefined) { |
| 268 | createDatabaseTrigger({ |
| 269 | projectRef: project?.ref, |
| 270 | connectionString: project?.connectionString, |
| 271 | payload, |
| 272 | }) |
| 273 | } else { |
| 274 | updateDatabaseTrigger({ |
| 275 | projectRef: project?.ref, |
| 276 | connectionString: project?.connectionString, |
| 277 | originalTrigger: selectedHook, |
| 278 | updatedTrigger: { |
| 279 | ...payload, |
| 280 | enabled_mode: 'ORIGIN', |
| 281 | events: payload.events.map(keyword), |
| 282 | }, |
| 283 | }) |
| 284 | } |
| 285 | } catch (error) { |
| 286 | console.error('Failed to get table editor:', error) |
| 287 | toast.error('Failed to get table editor') |
| 288 | } finally { |
| 289 | setIsLoadingTable(false) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // This is intentionally kept outside of the useConfirmOnClose hook to force RHF to update the isDirty state. |
| 294 | const isDirty = form.formState.isDirty |
| 295 | const { confirmOnClose, modalProps } = useConfirmOnClose({ |
| 296 | checkIsDirty: () => isDirty, |
| 297 | onClose: () => onClose(), |
| 298 | }) |
| 299 | |
| 300 | return ( |
| 301 | <> |
| 302 | <SidePanel |
| 303 | size="xlarge" |
| 304 | visible={visible} |
| 305 | header={ |
| 306 | selectedHook === undefined ? ( |
| 307 | 'Create a new database webhook' |
| 308 | ) : ( |
| 309 | <> |
| 310 | Update webhook <code className="text-sm">{selectedHook.name}</code> |
| 311 | </> |
| 312 | ) |
| 313 | } |
| 314 | className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out" |
| 315 | onConfirm={() => {}} |
| 316 | onCancel={confirmOnClose} |
| 317 | customFooter={ |
| 318 | <div className="flex w-full justify-end space-x-3 border-t border-default px-3 py-4"> |
| 319 | <Button |
| 320 | size="tiny" |
| 321 | type="default" |
| 322 | htmlType="button" |
| 323 | onClick={confirmOnClose} |
| 324 | disabled={isSubmitting} |
| 325 | > |
| 326 | Cancel |
| 327 | </Button> |
| 328 | <Button |
| 329 | size="tiny" |
| 330 | type="primary" |
| 331 | htmlType="submit" |
| 332 | form={FORM_ID} |
| 333 | disabled={isSubmitting} |
| 334 | loading={isSubmitting} |
| 335 | > |
| 336 | {selectedHook === undefined ? 'Create webhook' : 'Update webhook'} |
| 337 | </Button> |
| 338 | </div> |
| 339 | } |
| 340 | > |
| 341 | <Form {...form}> |
| 342 | <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}> |
| 343 | <FormContents form={form} selectedHook={selectedHook} /> |
| 344 | </form> |
| 345 | </Form> |
| 346 | </SidePanel> |
| 347 | <DiscardChangesConfirmationDialog {...modalProps} /> |
| 348 | </> |
| 349 | ) |
| 350 | } |