EditHookPanel.tsx 11 KB

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