mirror of
https://github.com/supabase/supabase.git
synced 2026-06-17 13:14:06 +08:00
## Problem Our `<Button>` component breaks the default `button` contract by redefining the `type` prop to set its variant (`primary`, `default`, etc) instead of the button type (`submit`, `button`, etc). This is confusing and forces to write more code when using it with shadcn components that expect/inject the standard button props. ## Solution - rename the `type` prop to `variant` - rename the `htmlType` prop to `type` - propagate the changes where necessary - format code ## How to test As this is just prop renaming, if it builds it's ok --------- Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
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.supabase.${restUrlTld}/`) ||
|
|
url.includes(`https://${ref}.supabase.${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),
|
|
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] ?? '',
|
|
})
|
|
? 'supabase_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] ?? '',
|
|
})
|
|
? 'supabase_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: 'supabase_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"
|
|
variant="default"
|
|
type="button"
|
|
onClick={confirmOnClose}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="tiny"
|
|
variant="primary"
|
|
type="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} />
|
|
</>
|
|
)
|
|
}
|