EventTriggersList.tsx326 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { DatabaseZap, Plus, Search } from 'lucide-react'
3import { parseAsJson, parseAsString, useQueryState } from 'nuqs'
4import { useMemo, useRef, useState } from 'react'
5import { toast } from 'sonner'
6import {
7 Button,
8 Card,
9 InputGroup,
10 InputGroupAddon,
11 InputGroupInput,
12 Table,
13 TableBody,
14 TableHead,
15 TableHeader,
16 TableRow,
17} from 'ui'
18import { EmptyStatePresentational } from 'ui-patterns'
19import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
20
21import { EventTriggerList } from './EventTriggerList'
22import { generateEventTriggerCreateSQL, type EventTrigger } from './EventTriggerList.utils'
23import { DEFAULT_EVENT_TRIGGER_SQL, EVENT_TRIGGER_TEMPLATES } from './EventTriggers.constants'
24import { DeleteEventTrigger } from '@/components/interfaces/Database/Triggers/DeleteEventTrigger'
25import {
26 ReportsSelectFilter,
27 selectFilterSchema,
28} from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
29import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
30import AlertError from '@/components/ui/AlertError'
31import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
32import { DocsButton } from '@/components/ui/DocsButton'
33import { Shortcut } from '@/components/ui/Shortcut'
34import { useDatabaseEventTriggerDeleteMutation } from '@/data/database-event-triggers/database-event-trigger-delete-mutation'
35import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
36import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
37import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
38import { DOCS_URL } from '@/lib/constants'
39import { onSearchInputEscape } from '@/lib/keyboard'
40import { EMPTY_ARR } from '@/lib/void'
41import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
42import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
43import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
44import { useShortcut } from '@/state/shortcuts/useShortcut'
45import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
46
47const DEFAULT_OWNER_FILTER = ['postgres']
48
49export const EventTriggersList = () => {
50 const { data: project } = useSelectedProjectQuery()
51 const [filterString, setFilterString] = useQueryState(
52 'search',
53 parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
54 )
55 const [ownerFilter, setOwnerFilter] = useQueryState(
56 'owner',
57 parseAsJson(selectFilterSchema.parse)
58 )
59 const ownerFilterValue = ownerFilter ?? DEFAULT_OWNER_FILTER
60 const [triggerToDelete, setTriggerToDelete] = useState<EventTrigger | null>(null)
61 const searchInputRef = useRef<HTMLInputElement>(null)
62 const { openSidebar } = useSidebarManagerSnapshot()
63 const aiSnap = useAiAssistantStateSnapshot()
64 const {
65 setValue: setEditorPanelValue,
66 setTemplates: setEditorPanelTemplates,
67 setInitialPrompt: setEditorPanelInitialPrompt,
68 } = useEditorPanelStateSnapshot()
69
70 const { can: canUpdateEventTriggers } = useAsyncCheckPermissions(
71 PermissionAction.TENANT_SQL_ADMIN_WRITE,
72 'triggers'
73 )
74
75 const {
76 data: eventTriggers = EMPTY_ARR,
77 error,
78 isPending,
79 isError,
80 } = useDatabaseEventTriggersQuery({
81 projectRef: project?.ref,
82 connectionString: project?.connectionString,
83 })
84
85 const { mutate: deleteEventTrigger, isPending: isDeletingEventTrigger } =
86 useDatabaseEventTriggerDeleteMutation({
87 onSuccess: (_, variables) => {
88 toast.success(`Successfully removed ${variables.trigger.name}`)
89 setTriggerToDelete(null)
90 },
91 onError: () => {
92 setTriggerToDelete(null)
93 },
94 })
95
96 const createEventTrigger = () => {
97 setEditorPanelInitialPrompt('Create a new event trigger that...')
98 setEditorPanelValue(DEFAULT_EVENT_TRIGGER_SQL)
99 setEditorPanelTemplates(EVENT_TRIGGER_TEMPLATES)
100 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
101 }
102
103 const editEventTrigger = (trigger: EventTrigger) => {
104 setEditorPanelInitialPrompt(`Update the event trigger "${trigger.name}" that...`)
105 const sql = generateEventTriggerCreateSQL(trigger)
106 setEditorPanelValue(sql.length > 0 ? sql : DEFAULT_EVENT_TRIGGER_SQL)
107 setEditorPanelTemplates([])
108 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
109 }
110
111 const editEventTriggerWithAssistant = (trigger: EventTrigger) => {
112 const sql = generateEventTriggerCreateSQL(trigger)
113 openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
114 aiSnap.newChat({
115 name: `Update event trigger ${trigger.name}`,
116 initialInput: `Update this event trigger to...`,
117 suggestions: {
118 title:
119 'I can help you update this event trigger, here are a few example prompts to get you started:',
120 prompts: [
121 {
122 label: 'Change firing event',
123 description: 'Update this trigger to run on a different event',
124 },
125 {
126 label: 'Update tags',
127 description: 'Modify the tags used to filter which commands it fires for',
128 },
129 {
130 label: 'Rename trigger',
131 description: 'Rename this event trigger',
132 },
133 ],
134 },
135 sqlSnippets: [sql.length > 0 ? sql : DEFAULT_EVENT_TRIGGER_SQL],
136 })
137 }
138
139 const duplicateEventTrigger = (trigger: EventTrigger) => {
140 const duplicateTrigger = { ...trigger, name: `${trigger.name}_duplicate` }
141 setEditorPanelInitialPrompt('Create a new event trigger that...')
142 const sql = generateEventTriggerCreateSQL(duplicateTrigger)
143 setEditorPanelValue(sql.length > 0 ? sql : DEFAULT_EVENT_TRIGGER_SQL)
144 setEditorPanelTemplates([])
145 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
146 }
147
148 const handleDeleteEventTrigger = (trigger: EventTrigger) => {
149 setTriggerToDelete(trigger)
150 }
151
152 const ownerOptions = useMemo(() => {
153 const uniqueOwners = Array.from(
154 new Set(eventTriggers.map((trigger) => trigger.owner).filter(Boolean) as string[])
155 ).sort((a, b) => a.localeCompare(b))
156
157 return uniqueOwners.includes('postgres') ? uniqueOwners : ['postgres', ...uniqueOwners]
158 }, [eventTriggers])
159
160 useShortcut(
161 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
162 () => {
163 searchInputRef.current?.focus()
164 searchInputRef.current?.select()
165 },
166 { label: 'Search event triggers' }
167 )
168
169 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
170 setFilterString('')
171 setOwnerFilter(null)
172 })
173
174 const showEmptyState = useMemo(() => {
175 const hasPostgresOwnerTriggers = eventTriggers.some((trigger) => trigger.owner === 'postgres')
176 return (
177 ownerFilterValue.includes('postgres') &&
178 ownerFilterValue.length === 1 &&
179 !hasPostgresOwnerTriggers
180 )
181 }, [eventTriggers, ownerFilterValue])
182
183 if (isPending) {
184 return <GenericSkeletonLoader />
185 }
186
187 if (isError) {
188 return <AlertError error={error} subject="Failed to retrieve event triggers" />
189 }
190
191 return (
192 <div className="space-y-4">
193 <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
194 <div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
195 <InputGroup className="w-full lg:w-52">
196 <InputGroupInput
197 ref={searchInputRef}
198 size="tiny"
199 placeholder="Search for a trigger"
200 value={filterString}
201 onChange={(e) => setFilterString(e.target.value)}
202 onKeyDown={onSearchInputEscape(filterString, setFilterString)}
203 />
204 <InputGroupAddon>
205 <Search />
206 </InputGroupAddon>
207 </InputGroup>
208 <ReportsSelectFilter
209 label="Owner"
210 options={ownerOptions.map((owner) => ({
211 label: owner,
212 value: owner,
213 }))}
214 value={ownerFilterValue}
215 onChange={setOwnerFilter}
216 showSearch
217 />
218 </div>
219 <div className="flex items-center gap-2">
220 <DocsButton href={`${DOCS_URL}/guides/database/postgres/event-triggers`} />
221 {canUpdateEventTriggers ? (
222 <Shortcut
223 id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
224 label="Create new event trigger"
225 onTrigger={createEventTrigger}
226 side="bottom"
227 >
228 <Button type="primary" icon={<Plus size={12} />} onClick={createEventTrigger}>
229 New trigger
230 </Button>
231 </Shortcut>
232 ) : (
233 <ButtonTooltip
234 type="primary"
235 icon={<Plus size={12} />}
236 disabled
237 tooltip={{
238 content: {
239 side: 'bottom',
240 text: 'You need additional permissions to add a new event trigger',
241 },
242 }}
243 >
244 New trigger
245 </ButtonTooltip>
246 )}
247 </div>
248 </div>
249
250 {showEmptyState ? (
251 <EmptyStatePresentational
252 icon={DatabaseZap}
253 title="No event triggers yet"
254 description="Event triggers run on database-level events like DDL commands."
255 >
256 <ButtonTooltip
257 type="default"
258 disabled={!canUpdateEventTriggers}
259 onClick={createEventTrigger}
260 tooltip={{
261 content: {
262 side: 'bottom',
263 text: !canUpdateEventTriggers
264 ? 'You need additional permissions to add a new event trigger'
265 : undefined,
266 },
267 }}
268 >
269 New trigger
270 </ButtonTooltip>
271 </EmptyStatePresentational>
272 ) : (
273 <div className="w-full overflow-hidden overflow-x-auto">
274 <Card>
275 <Table>
276 <TableHeader>
277 <TableRow>
278 <TableHead key="name">Name</TableHead>
279 <TableHead key="event">Event</TableHead>
280 <TableHead key="function">Function</TableHead>
281 <TableHead key="tags">Tags</TableHead>
282 <TableHead key="enabled" className="w-20">
283 Enabled
284 </TableHead>
285 <TableHead key="buttons" className="w-1/12"></TableHead>
286 </TableRow>
287 </TableHeader>
288 <TableBody>
289 <EventTriggerList
290 eventTriggers={eventTriggers}
291 filterString={filterString}
292 ownerFilter={ownerFilterValue}
293 canEdit={canUpdateEventTriggers}
294 onEditTrigger={editEventTrigger}
295 onEditTriggerWithAssistant={editEventTriggerWithAssistant}
296 onDuplicateTrigger={duplicateEventTrigger}
297 onDeleteTrigger={handleDeleteEventTrigger}
298 />
299 </TableBody>
300 </Table>
301 </Card>
302 </div>
303 )}
304
305 <DeleteEventTrigger
306 trigger={triggerToDelete ?? undefined}
307 visible={!!triggerToDelete}
308 onCancel={() => setTriggerToDelete(null)}
309 onDelete={() => {
310 if (!project) {
311 toast.error('Project is required to delete an event trigger')
312 return
313 }
314 if (!triggerToDelete) return
315
316 deleteEventTrigger({
317 projectRef: project.ref,
318 connectionString: project.connectionString,
319 trigger: triggerToDelete,
320 })
321 }}
322 isLoading={isDeletingEventTrigger}
323 />
324 </div>
325 )
326}