EditHookPanel.tsx350 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { keyword } from '@supabase/pg-meta'
3import type { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta'
4import { useQueryClient } from '@tanstack/react-query'
5import { useParams } from 'common'
6import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
7import { useEffect, useRef, useState } from 'react'
8import { SubmitHandler, useForm } from 'react-hook-form'
9import { toast } from 'sonner'
10import { Button, Form, SidePanel } from 'ui'
11
12import { FormSchema, WebhookFormValues } from './EditHookPanel.constants'
13import { FormContents } from './FormContents'
14import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
15import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation'
16import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-transaction-mutation'
17import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query'
18import { tableEditorQueryOptions } from '@/data/table-editor/table-editor-query'
19import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
20import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
21import { uuidv4 } from '@/lib/helpers'
22
23export type HTTPArgument = { id: string; name: string; value: string }
24
25export 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
37const FORM_ID = 'edit-hook-panel-form'
38
39const 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
59const 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
79export 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}