| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- 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<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 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<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: '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 (
- <>
- <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"
- type="default"
- htmlType="button"
- onClick={confirmOnClose}
- disabled={isSubmitting}
- >
- Cancel
- </Button>
- <Button
- size="tiny"
- type="primary"
- htmlType="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} />
- </>
- )
- }
|