Files
supabase/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx
Gildas Garcia 5d97339d41 chore: remove <Select> _Shadcn_ suffix (#45988)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `Select` components

## Solution

Remove it. No other changes

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Updated internal component architecture to standardize and simplify
the codebase. These changes improve code maintainability and consistency
across the application without affecting existing functionality or user
experience.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45988)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 16:39:57 +02:00

499 lines
19 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import type { PGTrigger } from '@supabase/pg-meta'
import { Terminal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Checkbox,
cn,
Form,
FormControl,
FormField,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import ChooseFunctionForm from './ChooseFunctionForm'
import {
TRIGGER_ENABLED_MODES,
TRIGGER_EVENTS,
TRIGGER_ORIENTATIONS,
TRIGGER_TYPES,
} from './Triggers.constants'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import FormBoxEmpty from '@/components/ui/FormBoxEmpty'
import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-mutation'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
const formId = 'create-trigger'
const FormSchema = z.object({
name: z
.string()
.min(1, 'Please provide a name for your trigger')
.regex(/^\S+$/, 'Name should not contain spaces or whitespaces'),
schema: z.string(),
table: z.string(),
activation: z.enum(['BEFORE', 'AFTER', 'INSTEAD OF']),
enabled_mode: z.enum(['ORIGIN', 'REPLICA', 'ALWAYS', 'DISABLED']),
orientation: z.enum(['ROW', 'STATEMENT']),
function_name: z.string().min(1, 'Please select a database function for your trigger to call'),
function_schema: z.string(),
events: z.array(z.string()).min(1, 'Please select at least one event'),
// For UI handling, not to be passed to the final request
tableId: z.string().optional(),
})
const defaultValues: z.infer<typeof FormSchema> = {
name: '',
schema: '',
table: '',
activation: 'AFTER',
orientation: 'ROW',
function_name: '',
function_schema: '',
enabled_mode: 'ORIGIN',
events: [],
}
interface TriggerSheetProps {
selectedTrigger?: PGTrigger
isDuplicatingTrigger?: boolean
open: boolean
onClose: () => void
}
export const TriggerSheet = ({
selectedTrigger,
isDuplicatingTrigger,
open,
onClose,
}: TriggerSheetProps) => {
const { data: project } = useSelectedProjectQuery()
const [showFunctionSelector, setShowFunctionSelector] = useState(false)
const { mutate: createDatabaseTrigger, isPending: isCreating } = useDatabaseTriggerCreateMutation(
{
onSuccess: () => {
toast.success(`Successfully created trigger`)
onClose()
},
onError: (error) => {
toast.error(`Failed to create trigger: ${error.message}`)
},
}
)
const { mutate: updateDatabaseTrigger, isPending: isUpdating } = useDatabaseTriggerUpdateMutation(
{
onSuccess: () => {
toast.success(`Successfully updated trigger`)
onClose()
},
onError: (error) => {
toast.error(`Failed to update trigger: ${error.message}`)
},
}
)
const { data = [], isSuccess: isSuccessTables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: protectedSchemas, isSuccess: isSuccessProtectedSchemas } = useProtectedSchemas()
const isSuccess = isSuccessTables && isSuccessProtectedSchemas
const tables = data
.sort((a, b) => a.schema.localeCompare(b.schema))
.filter((a) => !protectedSchemas.find((s) => s.name === a.schema))
const isEditing = !isDuplicatingTrigger && !!selectedTrigger
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onSubmit',
reValidateMode: 'onSubmit',
resolver: zodResolver(FormSchema),
defaultValues,
})
const { function_name, function_schema } = form.watch()
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
onClose,
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!project) return console.error('Project is required')
const { tableId, ...payload } = values
if (isEditing) {
updateDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
originalTrigger: selectedTrigger,
payload: { name: payload.name, enabled_mode: payload.enabled_mode },
})
} else {
createDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
payload,
})
}
}
useEffect(() => {
if (open && isSuccess) {
form.clearErrors()
if (isDuplicatingTrigger && selectedTrigger) {
const initalSelectedTable = tables.find((t) => t.name === selectedTrigger.table)
form.reset({
...selectedTrigger,
tableId: initalSelectedTable?.id.toString(),
table: initalSelectedTable?.name,
schema: initalSelectedTable?.schema,
})
} else if (isEditing) {
form.reset(selectedTrigger)
} else if (tables.length > 0) {
form.reset({
...defaultValues,
tableId: tables[0].id.toString(),
table: tables[0].name,
schema: tables[0].schema,
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isSuccess])
return (
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>
{isDuplicatingTrigger
? 'Duplicate trigger'
: isEditing
? `Edit database trigger: ${selectedTrigger.name}`
: 'Create a new database trigger'}
</SheetTitle>
</SheetHeader>
<Form {...form}>
<form
id={formId}
className="flex-1 flex flex-col gap-y-6 overflow-auto py-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Name of trigger"
description="Do not use spaces/whitespace."
>
<FormControl>
<Input {...field} placeholder="Name of trigger" />
</FormControl>
</FormItemLayout>
)}
/>
{isEditing ? (
<FormField
name="enabled_mode"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Enabled mode"
description="Determines if a trigger should or should not fire. Can also be used to disable a trigger, but not delete it."
>
<FormControl>
<Select defaultValue={field.value} onValueChange={field.onChange}>
<SelectTrigger className="col-span-8">
{
TRIGGER_ENABLED_MODES.find((option) => option.value === field.value)
?.label
}
</SelectTrigger>
<SelectContent>
{TRIGGER_ENABLED_MODES.map((option) => (
<SelectItem key={option.value} value={option.value}>
<p className="text-foreground">{option.label}</p>
<p className="text-foreground-lighter">{option.description}</p>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
) : (
<>
<Separator />
<FormField
name="tableId"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Table"
description="Trigger will watch for changes on this table"
>
<FormControl>
<Select
defaultValue={field.value}
onValueChange={(val) => {
// mark table ID as dirty to trigger validation
field.onChange(val)
const table = tables.find((x) => x.id.toString() === val)
if (table) {
form.setValue('table', table.name, { shouldDirty: true })
form.setValue('schema', table.schema, { shouldDirty: true })
}
}}
>
<SelectTrigger className="col-span-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.id} value={table.id.toString()}>
<span className="text-foreground-light">{table.schema}.</span>
<span className="text-foreground">{table.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
name="events"
control={form.control}
render={() => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Events"
description="These are the events that are watched by the trigger, only the events selected above will fire the trigger on the table you've selected."
>
{TRIGGER_EVENTS.map((event) => (
<FormField
key={event.value}
control={form.control}
name="events"
render={({ field }) => (
<FormItemLayout
hideMessage
layout="flex"
label={event.label}
description={event.description}
>
<FormControl>
<Checkbox
className="translate-y-[2px]"
checked={field.value?.includes(event.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, event.value])
: field.onChange(
field.value?.filter((value) => value !== event.value)
)
}}
/>
</FormControl>
</FormItemLayout>
)}
/>
))}
</FormItemLayout>
)}
/>
<FormField
name="activation"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Trigger type"
description="Determines when your trigger fires"
>
<FormControl>
<Select defaultValue={field.value} onValueChange={field.onChange}>
<SelectTrigger className="col-span-8">
{TRIGGER_TYPES.find((option) => option.value === field.value)?.label}
</SelectTrigger>
<SelectContent>
{TRIGGER_TYPES.map((option) => (
<SelectItem key={option.value} value={option.value}>
<p className="text-foreground">{option.label}</p>
<p className="text-foreground-lighter">{option.description}</p>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
name="orientation"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Orientation"
description="Identifies whether the trigger fires once for each processed row or once for each statement"
>
<FormControl>
<Select defaultValue={field.value} onValueChange={field.onChange}>
<SelectTrigger className="col-span-8">
{
TRIGGER_ORIENTATIONS.find((option) => option.value === field.value)
?.label
}
</SelectTrigger>
<SelectContent>
{TRIGGER_ORIENTATIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<p className="text-foreground">{option.label}</p>
<p className="text-foreground-lighter">{option.description}</p>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
<Separator />
<FormField
name="function_name"
control={form.control}
render={() => (
<FormItemLayout layout="vertical" className="px-5">
<FormControl>
<div className="flex flex-col gap-y-2">
<p className="text-sm">Function to trigger</p>
{function_name.length === 0 ? (
<button
type="button"
className={cn(
'relative w-full rounded-sm border border-default',
'bg-surface-200 px-5 py-1 shadow-xs transition-all',
'hover:border-strong hover:bg-overlay-hover'
)}
onClick={() => setShowFunctionSelector(true)}
>
<FormBoxEmpty
icon={<Terminal size={14} strokeWidth={2} />}
text="Choose a function to trigger"
/>
</button>
) : (
<div
className={cn(
'relative w-full flex items-center justify-between',
'space-x-3 px-5 py-4 border border-default',
'rounded-sm shadow-xs transition-shadow'
)}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-sm bg-foreground text-background focus-within:bg-foreground/10">
<Terminal size="18" strokeWidth={2} width={14} />
</div>
<p>
<span className="text-sm text-foreground-light">
{function_schema}
</span>
.
<span className="text-sm text-foreground">{function_name}</span>
</p>
</div>
<Button
type="default"
onClick={() => setShowFunctionSelector(true)}
>
Change function
</Button>
</div>
)}
</div>
</FormControl>
</FormItemLayout>
)}
/>
</>
)}
</form>
</Form>
<SheetFooter className="shrink-0">
<Button
type="default"
htmlType="reset"
disabled={isCreating || isUpdating}
onClick={confirmOnClose}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isCreating || isUpdating}>
{isEditing ? 'Save' : 'Create'} trigger
</Button>
</SheetFooter>
<DiscardChangesConfirmationDialog {...modalProps} />
</SheetContent>
</Sheet>
<ChooseFunctionForm
visible={showFunctionSelector}
setVisible={setShowFunctionSelector}
onChange={(fn) => {
form.setValue('function_name', fn.name, { shouldDirty: true })
form.setValue('function_schema', fn.schema, { shouldDirty: true })
}}
/>
</>
)
}