Files
Danny WhiteandJoshen Lim 8a5ad58f81 chore(studio): replace CloseConfirmationModal with DiscardChangesConfirmationDialog (#43430)
## What kind of change does this PR introduce?

Form handling improvement.

## What is the current behavior?

https://github.com/supabase/supabase/pull/43201/ standardised our
discard changes behaviour with a shared hook and
`DiscardChangesConfirmationDialog` component. But many forms and sheets
still:

1. Don’t have any Discard-confirm close behaviour, making it too easy to
make accidental discards
2. Use a more complicated, manually-created `CloseConfirmationModal`
approach

## What is the new behavior?

- Replaced all instances of `#2` above that had `CloseConfirmationModal`
with `DiscardChangesConfirmationDialog` and its hook
- Improved design system documentation around dirty form dismissal

| Before | After |
| --- | --- |
| <img width="987" height="569" alt="Mercor Apexroles Foo
Supabase-9A40EC7C-F335-4B26-B567-450FC0845463"
src="https://github.com/user-attachments/assets/363bed82-34d2-4cc8-9164-6d18cfdbdbbc"
/> | <img width="987" height="569" alt="Mercor Apexroles Foo
Supabase-F427F1FA-DECC-4194-B663-A9E5A6F285A1"
src="https://github.com/user-attachments/assets/d49fafdc-a5c2-46df-9b67-ec42bacbe716"
/> |

## To test

Try editing values these sheets in staging, then blurring the sheet or
pressing `esc`:

- CreateQueueSheet.tsx
- CronJobsTab.tsx
- CronJobPage.tsx
- EditWrapperSheet.tsx
- OverviewTab.tsx
- WrappersTab.tsx
- CreateFunction/index.tsx
- EditHookPanel.tsx
- TriggerSheet.tsx
- SidePanelEditor.tsx
- EditSecretSheet.tsx
- PolicyEditorModal/index.tsx
- PolicyEditorPanel/index.tsx

## Still to come

- [ ] Incrementally take on `#1`: implement
`DiscardChangesConfirmationDialog` and its hook in sheets or dialog
forms that have no dirty form dismissal handling

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-03-06 03:17:35 +00:00

347 lines
11 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import type { PostgresTrigger } from '@supabase/postgres-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_Shadcn_, 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?: PostgresTrigger): 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?: PostgresTrigger): 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' },
})
}
} 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_Shadcn_ {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<FormContents form={form} selectedHook={selectedHook} />
</form>
</Form_Shadcn_>
</SidePanel>
<DiscardChangesConfirmationDialog {...modalProps} />
</>
)
}