TriggerSheet.tsx498 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import type { PGTrigger } from '@supabase/pg-meta'
3import { Terminal } from 'lucide-react'
4import { useEffect, useState } from 'react'
5import { SubmitHandler, useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import {
8 Button,
9 Checkbox,
10 cn,
11 Form,
12 FormControl,
13 FormField,
14 Input,
15 Select,
16 SelectContent,
17 SelectItem,
18 SelectTrigger,
19 SelectValue,
20 Separator,
21 Sheet,
22 SheetContent,
23 SheetFooter,
24 SheetHeader,
25 SheetTitle,
26} from 'ui'
27import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
28import * as z from 'zod'
29
30import ChooseFunctionForm from './ChooseFunctionForm'
31import {
32 TRIGGER_ENABLED_MODES,
33 TRIGGER_EVENTS,
34 TRIGGER_ORIENTATIONS,
35 TRIGGER_TYPES,
36} from './Triggers.constants'
37import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
38import FormBoxEmpty from '@/components/ui/FormBoxEmpty'
39import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation'
40import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-mutation'
41import { useTablesQuery } from '@/data/tables/tables-query'
42import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
43import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
44import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
45
46const formId = 'create-trigger'
47
48const FormSchema = z.object({
49 name: z
50 .string()
51 .min(1, 'Please provide a name for your trigger')
52 .regex(/^\S+$/, 'Name should not contain spaces or whitespaces'),
53 schema: z.string(),
54 table: z.string(),
55 activation: z.enum(['BEFORE', 'AFTER', 'INSTEAD OF']),
56 enabled_mode: z.enum(['ORIGIN', 'REPLICA', 'ALWAYS', 'DISABLED']),
57 orientation: z.enum(['ROW', 'STATEMENT']),
58 function_name: z.string().min(1, 'Please select a database function for your trigger to call'),
59 function_schema: z.string(),
60 events: z.array(z.string()).min(1, 'Please select at least one event'),
61
62 // For UI handling, not to be passed to the final request
63 tableId: z.string().optional(),
64})
65
66const defaultValues: z.infer<typeof FormSchema> = {
67 name: '',
68 schema: '',
69 table: '',
70 activation: 'AFTER',
71 orientation: 'ROW',
72 function_name: '',
73 function_schema: '',
74 enabled_mode: 'ORIGIN',
75 events: [],
76}
77
78interface TriggerSheetProps {
79 selectedTrigger?: PGTrigger
80 isDuplicatingTrigger?: boolean
81 open: boolean
82 onClose: () => void
83}
84
85export const TriggerSheet = ({
86 selectedTrigger,
87 isDuplicatingTrigger,
88 open,
89 onClose,
90}: TriggerSheetProps) => {
91 const { data: project } = useSelectedProjectQuery()
92
93 const [showFunctionSelector, setShowFunctionSelector] = useState(false)
94
95 const { mutate: createDatabaseTrigger, isPending: isCreating } = useDatabaseTriggerCreateMutation(
96 {
97 onSuccess: () => {
98 toast.success(`Successfully created trigger`)
99 onClose()
100 },
101 onError: (error) => {
102 toast.error(`Failed to create trigger: ${error.message}`)
103 },
104 }
105 )
106 const { mutate: updateDatabaseTrigger, isPending: isUpdating } = useDatabaseTriggerUpdateMutation(
107 {
108 onSuccess: () => {
109 toast.success(`Successfully updated trigger`)
110 onClose()
111 },
112 onError: (error) => {
113 toast.error(`Failed to update trigger: ${error.message}`)
114 },
115 }
116 )
117
118 const { data = [], isSuccess: isSuccessTables } = useTablesQuery({
119 projectRef: project?.ref,
120 connectionString: project?.connectionString,
121 })
122 const { data: protectedSchemas, isSuccess: isSuccessProtectedSchemas } = useProtectedSchemas()
123 const isSuccess = isSuccessTables && isSuccessProtectedSchemas
124
125 const tables = data
126 .sort((a, b) => a.schema.localeCompare(b.schema))
127 .filter((a) => !protectedSchemas.find((s) => s.name === a.schema))
128 const isEditing = !isDuplicatingTrigger && !!selectedTrigger
129
130 const form = useForm<z.infer<typeof FormSchema>>({
131 mode: 'onSubmit',
132 reValidateMode: 'onSubmit',
133 resolver: zodResolver(FormSchema as any),
134 defaultValues,
135 })
136 const { function_name, function_schema } = form.watch()
137
138 const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
139 checkIsDirty: () => form.formState.isDirty,
140 onClose,
141 })
142
143 const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
144 if (!project) return console.error('Project is required')
145 const { tableId, ...payload } = values
146
147 if (isEditing) {
148 updateDatabaseTrigger({
149 projectRef: project?.ref,
150 connectionString: project?.connectionString,
151 originalTrigger: selectedTrigger,
152 payload: { name: payload.name, enabled_mode: payload.enabled_mode },
153 })
154 } else {
155 createDatabaseTrigger({
156 projectRef: project?.ref,
157 connectionString: project?.connectionString,
158 payload,
159 })
160 }
161 }
162
163 useEffect(() => {
164 if (open && isSuccess) {
165 form.clearErrors()
166
167 if (isDuplicatingTrigger && selectedTrigger) {
168 const initalSelectedTable = tables.find((t) => t.name === selectedTrigger.table)
169
170 form.reset({
171 ...selectedTrigger,
172 tableId: initalSelectedTable?.id.toString(),
173 table: initalSelectedTable?.name,
174 schema: initalSelectedTable?.schema,
175 })
176 } else if (isEditing) {
177 form.reset(selectedTrigger)
178 } else if (tables.length > 0) {
179 form.reset({
180 ...defaultValues,
181 tableId: tables[0].id.toString(),
182 table: tables[0].name,
183 schema: tables[0].schema,
184 })
185 }
186 }
187 // eslint-disable-next-line react-hooks/exhaustive-deps
188 }, [open, isSuccess])
189
190 return (
191 <>
192 <Sheet open={open} onOpenChange={handleOpenChange}>
193 <SheetContent size="lg" className="flex flex-col gap-0">
194 <SheetHeader>
195 <SheetTitle>
196 {isDuplicatingTrigger
197 ? 'Duplicate trigger'
198 : isEditing
199 ? `Edit database trigger: ${selectedTrigger.name}`
200 : 'Create a new database trigger'}
201 </SheetTitle>
202 </SheetHeader>
203
204 <Form {...form}>
205 <form
206 id={formId}
207 className="flex-1 flex flex-col gap-y-6 overflow-auto py-6"
208 onSubmit={form.handleSubmit(onSubmit)}
209 >
210 <FormField
211 name="name"
212 control={form.control}
213 render={({ field }) => (
214 <FormItemLayout
215 className="px-5"
216 layout="horizontal"
217 label="Name of trigger"
218 description="Do not use spaces/whitespace."
219 >
220 <FormControl>
221 <Input {...field} placeholder="Name of trigger" />
222 </FormControl>
223 </FormItemLayout>
224 )}
225 />
226
227 {isEditing ? (
228 <FormField
229 name="enabled_mode"
230 control={form.control}
231 render={({ field }) => (
232 <FormItemLayout
233 className="px-5"
234 layout="horizontal"
235 label="Enabled mode"
236 description="Determines if a trigger should or should not fire. Can also be used to disable a trigger, but not delete it."
237 >
238 <FormControl>
239 <Select defaultValue={field.value} onValueChange={field.onChange}>
240 <SelectTrigger className="col-span-8">
241 {
242 TRIGGER_ENABLED_MODES.find((option) => option.value === field.value)
243 ?.label
244 }
245 </SelectTrigger>
246 <SelectContent>
247 {TRIGGER_ENABLED_MODES.map((option) => (
248 <SelectItem key={option.value} value={option.value}>
249 <p className="text-foreground">{option.label}</p>
250 <p className="text-foreground-lighter">{option.description}</p>
251 </SelectItem>
252 ))}
253 </SelectContent>
254 </Select>
255 </FormControl>
256 </FormItemLayout>
257 )}
258 />
259 ) : (
260 <>
261 <Separator />
262
263 <FormField
264 name="tableId"
265 control={form.control}
266 render={({ field }) => (
267 <FormItemLayout
268 className="px-5"
269 layout="horizontal"
270 label="Table"
271 description="Trigger will watch for changes on this table"
272 >
273 <FormControl>
274 <Select
275 defaultValue={field.value}
276 onValueChange={(val) => {
277 // mark table ID as dirty to trigger validation
278 field.onChange(val)
279 const table = tables.find((x) => x.id.toString() === val)
280 if (table) {
281 form.setValue('table', table.name, { shouldDirty: true })
282 form.setValue('schema', table.schema, { shouldDirty: true })
283 }
284 }}
285 >
286 <SelectTrigger className="col-span-8">
287 <SelectValue />
288 </SelectTrigger>
289 <SelectContent>
290 {tables.map((table) => (
291 <SelectItem key={table.id} value={table.id.toString()}>
292 <span className="text-foreground-light">{table.schema}.</span>
293 <span className="text-foreground">{table.name}</span>
294 </SelectItem>
295 ))}
296 </SelectContent>
297 </Select>
298 </FormControl>
299 </FormItemLayout>
300 )}
301 />
302
303 <FormField
304 name="events"
305 control={form.control}
306 render={() => (
307 <FormItemLayout
308 className="px-5"
309 layout="horizontal"
310 label="Events"
311 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."
312 >
313 {TRIGGER_EVENTS.map((event) => (
314 <FormField
315 key={event.value}
316 control={form.control}
317 name="events"
318 render={({ field }) => (
319 <FormItemLayout
320 hideMessage
321 layout="flex"
322 label={event.label}
323 description={event.description}
324 >
325 <FormControl>
326 <Checkbox
327 className="translate-y-[2px]"
328 checked={field.value?.includes(event.value)}
329 onCheckedChange={(checked) => {
330 return checked
331 ? field.onChange([...field.value, event.value])
332 : field.onChange(
333 field.value?.filter((value) => value !== event.value)
334 )
335 }}
336 />
337 </FormControl>
338 </FormItemLayout>
339 )}
340 />
341 ))}
342 </FormItemLayout>
343 )}
344 />
345
346 <FormField
347 name="activation"
348 control={form.control}
349 render={({ field }) => (
350 <FormItemLayout
351 className="px-5"
352 layout="horizontal"
353 label="Trigger type"
354 description="Determines when your trigger fires"
355 >
356 <FormControl>
357 <Select defaultValue={field.value} onValueChange={field.onChange}>
358 <SelectTrigger className="col-span-8">
359 {TRIGGER_TYPES.find((option) => option.value === field.value)?.label}
360 </SelectTrigger>
361 <SelectContent>
362 {TRIGGER_TYPES.map((option) => (
363 <SelectItem key={option.value} value={option.value}>
364 <p className="text-foreground">{option.label}</p>
365 <p className="text-foreground-lighter">{option.description}</p>
366 </SelectItem>
367 ))}
368 </SelectContent>
369 </Select>
370 </FormControl>
371 </FormItemLayout>
372 )}
373 />
374
375 <FormField
376 name="orientation"
377 control={form.control}
378 render={({ field }) => (
379 <FormItemLayout
380 className="px-5"
381 layout="horizontal"
382 label="Orientation"
383 description="Identifies whether the trigger fires once for each processed row or once for each statement"
384 >
385 <FormControl>
386 <Select defaultValue={field.value} onValueChange={field.onChange}>
387 <SelectTrigger className="col-span-8">
388 {
389 TRIGGER_ORIENTATIONS.find((option) => option.value === field.value)
390 ?.label
391 }
392 </SelectTrigger>
393 <SelectContent>
394 {TRIGGER_ORIENTATIONS.map((option) => (
395 <SelectItem key={option.value} value={option.value}>
396 <p className="text-foreground">{option.label}</p>
397 <p className="text-foreground-lighter">{option.description}</p>
398 </SelectItem>
399 ))}
400 </SelectContent>
401 </Select>
402 </FormControl>
403 </FormItemLayout>
404 )}
405 />
406
407 <Separator />
408
409 <FormField
410 name="function_name"
411 control={form.control}
412 render={() => (
413 <FormItemLayout layout="vertical" className="px-5">
414 <FormControl>
415 <div className="flex flex-col gap-y-2">
416 <p className="text-sm">Function to trigger</p>
417 {function_name.length === 0 ? (
418 <button
419 type="button"
420 className={cn(
421 'relative w-full rounded-sm border border-default',
422 'bg-surface-200 px-5 py-1 shadow-xs transition-all',
423 'hover:border-strong hover:bg-overlay-hover'
424 )}
425 onClick={() => setShowFunctionSelector(true)}
426 >
427 <FormBoxEmpty
428 icon={<Terminal size={14} strokeWidth={2} />}
429 text="Choose a function to trigger"
430 />
431 </button>
432 ) : (
433 <div
434 className={cn(
435 'relative w-full flex items-center justify-between',
436 'space-x-3 px-5 py-4 border border-default',
437 'rounded-sm shadow-xs transition-shadow'
438 )}
439 >
440 <div className="flex items-center gap-2">
441 <div className="flex h-6 w-6 items-center justify-center rounded-sm bg-foreground text-background focus-within:bg-foreground/10">
442 <Terminal size="18" strokeWidth={2} width={14} />
443 </div>
444 <p>
445 <span className="text-sm text-foreground-light">
446 {function_schema}
447 </span>
448 .
449 <span className="text-sm text-foreground">{function_name}</span>
450 </p>
451 </div>
452 <Button
453 type="default"
454 onClick={() => setShowFunctionSelector(true)}
455 >
456 Change function
457 </Button>
458 </div>
459 )}
460 </div>
461 </FormControl>
462 </FormItemLayout>
463 )}
464 />
465 </>
466 )}
467 </form>
468 </Form>
469
470 <SheetFooter className="shrink-0">
471 <Button
472 type="default"
473 htmlType="reset"
474 disabled={isCreating || isUpdating}
475 onClick={confirmOnClose}
476 >
477 Cancel
478 </Button>
479 <Button form={formId} htmlType="submit" loading={isCreating || isUpdating}>
480 {isEditing ? 'Save' : 'Create'} trigger
481 </Button>
482 </SheetFooter>
483
484 <DiscardChangesConfirmationDialog {...modalProps} />
485 </SheetContent>
486 </Sheet>
487
488 <ChooseFunctionForm
489 visible={showFunctionSelector}
490 setVisible={setShowFunctionSelector}
491 onChange={(fn) => {
492 form.setValue('function_name', fn.name, { shouldDirty: true })
493 form.setValue('function_schema', fn.schema, { shouldDirty: true })
494 }}
495 />
496 </>
497 )
498}