| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- 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 as any),
- 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 })
- }}
- />
- </>
- )
- }
|