TriggersList.tsx372 lines · main
1import { safeSql } from '@supabase/pg-meta'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { DatabaseZap, Search } from 'lucide-react'
4import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
5import { useEffect, useRef, useState } from 'react'
6import { toast } from 'sonner'
7import {
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 { CreateTriggerButtons } from './CreateTriggerButtons'
22import { TriggerList } from './TriggerList'
23import { generateTriggerCreateSQL, type PostgresTrigger } from './TriggerList.utils'
24import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
25import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
26import { TriggerSheet } from '@/components/interfaces/Database/Triggers/TriggerSheet'
27import {
28 ReportsSelectFilter,
29 selectFilterSchema,
30} from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
31import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
32import AlertError from '@/components/ui/AlertError'
33import { DocsButton } from '@/components/ui/DocsButton'
34import SchemaSelector from '@/components/ui/SchemaSelector'
35import { Shortcut } from '@/components/ui/Shortcut'
36import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
37import { useDatabaseTriggerDeleteMutation } from '@/data/database-triggers/database-trigger-delete-mutation'
38import { useDatabaseTriggersQuery } from '@/data/database-triggers/database-triggers-query'
39import { useTablesQuery } from '@/data/tables/tables-query'
40import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
41import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
42import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
43import { useIsProtectedSchema, useProtectedSchemas } from '@/hooks/useProtectedSchemas'
44import { DOCS_URL } from '@/lib/constants'
45import { onSearchInputEscape } from '@/lib/keyboard'
46import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
47import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
48import { useShortcut } from '@/state/shortcuts/useShortcut'
49import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
50
51export const TriggersList = () => {
52 const { data: project } = useSelectedProjectQuery()
53 const { openSidebar } = useSidebarManagerSnapshot()
54 const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
55
56 const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
57 const [tablesFilter, setTablesFilter] = useQueryState(
58 'tables',
59 parseAsJson(selectFilterSchema.parse).withDefault([])
60 )
61
62 const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
63 const searchInputRef = useRef<HTMLInputElement>(null)
64
65 const isInlineEditorEnabled = useIsInlineEditorEnabled()
66 const {
67 templates: editorPanelTemplates,
68 setValue: setEditorPanelValue,
69 setTemplates: setEditorPanelTemplates,
70 setInitialPrompt: setEditorPanelInitialPrompt,
71 } = useEditorPanelStateSnapshot()
72
73 const { data: protectedSchemas } = useProtectedSchemas()
74 const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
75
76 const { data = [] } = useTablesQuery({
77 projectRef: project?.ref,
78 connectionString: project?.connectionString,
79 })
80 const hasTables =
81 data.filter((a) => !protectedSchemas.find((s) => s.name === a.schema)).length > 0
82
83 const {
84 data: triggers = [],
85 error,
86 isPending,
87 isSuccess,
88 isError,
89 } = useDatabaseTriggersQuery({
90 projectRef: project?.ref,
91 connectionString: project?.connectionString,
92 })
93
94 const { can: canCreateTriggers } = useAsyncCheckPermissions(
95 PermissionAction.TENANT_SQL_ADMIN_WRITE,
96 'triggers'
97 )
98
99 const [showCreateTriggerForm, setShowCreateTriggerForm] = useQueryState(
100 'new',
101 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
102 )
103
104 const [triggerIdToEdit, setTriggerToEdit] = useQueryState('edit', parseAsString)
105 const triggerToEdit = triggers?.find((fn) => fn.id.toString() === triggerIdToEdit)
106
107 const [triggerIdToDuplicate, setTriggerToDuplicate] = useQueryState('duplicate', parseAsString)
108 const triggerToDuplicate = triggers?.find((fn) => fn.id.toString() === triggerIdToDuplicate)
109
110 const [triggerIdToDelete, setTriggerToDelete] = useQueryState('delete', parseAsString)
111 const triggerToDelete = triggers?.find((fn) => fn.id.toString() === triggerIdToDelete)
112
113 const {
114 mutate: deleteDatabaseTrigger,
115 isPending: isDeletingTrigger,
116 isSuccess: isSuccessDelete,
117 } = useDatabaseTriggerDeleteMutation({
118 onSuccess: (_, variables) => {
119 toast.success(`Successfully removed ${variables.trigger.name}`)
120 setTriggerToDelete(null)
121 },
122 })
123
124 const createTrigger = () => {
125 setTriggerToDuplicate(null)
126 if (isInlineEditorEnabled) {
127 setEditorPanelInitialPrompt('Create a new database trigger that...')
128 setEditorPanelValue(
129 safeSql`create trigger trigger_name
130after insert or update or delete on table_name
131for each row
132execute function function_name();`
133 )
134 if (editorPanelTemplates.length > 0) {
135 setEditorPanelTemplates([])
136 }
137 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
138 } else {
139 setShowCreateTriggerForm(true)
140 }
141 }
142
143 const editTrigger = (trigger: PostgresTrigger) => {
144 setTriggerToDuplicate(null)
145 if (isInlineEditorEnabled) {
146 setEditorPanelValue(generateTriggerCreateSQL(trigger))
147 setEditorPanelTemplates([])
148 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
149 } else {
150 setTriggerToEdit(trigger.id.toString())
151 }
152 }
153
154 const duplicateTrigger = (trigger: PostgresTrigger) => {
155 if (isInlineEditorEnabled) {
156 const dupTrigger = {
157 ...trigger,
158 name: `${trigger.name}_duplicate`,
159 }
160 setEditorPanelValue(generateTriggerCreateSQL(dupTrigger))
161 setEditorPanelTemplates([])
162 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
163 } else {
164 setTriggerToDuplicate(trigger.id.toString())
165 }
166 }
167
168 const onDeleteTrigger = () => {
169 if (!project) return console.error('Project is required')
170 if (!triggerToDelete) return console.error('Trigger ID is required')
171
172 deleteDatabaseTrigger({
173 projectRef: project.ref,
174 connectionString: project.connectionString,
175 trigger: triggerToDelete,
176 })
177 }
178
179 const schemaTriggers = triggers.filter((x) => x.schema === selectedSchema)
180 const tables = Array.from(new Set(schemaTriggers.map((x) => x.table))).sort()
181
182 const canAddTriggers = canCreateTriggers && !isSchemaLocked && hasTables
183
184 useShortcut(
185 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
186 () => {
187 searchInputRef.current?.focus()
188 searchInputRef.current?.select()
189 },
190 { label: 'Search triggers' }
191 )
192
193 useShortcut(
194 SHORTCUT_IDS.LIST_PAGE_NEW_ITEM,
195 () => {
196 createTrigger()
197 },
198 { enabled: canAddTriggers, label: 'Create new trigger' }
199 )
200
201 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
202 setFilterString('')
203 setTablesFilter([])
204 })
205
206 useEffect(() => {
207 if (isSuccess && !!triggerIdToEdit && !triggerToEdit) {
208 toast('Trigger not found')
209 setTriggerToEdit(null)
210 }
211 }, [isSuccess, setTriggerToEdit, triggerIdToEdit, triggerToEdit])
212
213 useEffect(() => {
214 if (isSuccess && !!triggerIdToDuplicate && !triggerToDuplicate) {
215 toast('Trigger not found')
216 setTriggerToDuplicate(null)
217 }
218 }, [isSuccess, triggerIdToDuplicate, triggerToDuplicate, setTriggerToDuplicate])
219
220 useEffect(() => {
221 if (isSuccess && !!triggerIdToDelete && !triggerToDelete && !isSuccessDelete) {
222 toast('Trigger not found')
223 setTriggerToDelete(null)
224 }
225 }, [isSuccess, triggerIdToDelete, triggerToDelete, isSuccessDelete, setTriggerToDelete])
226
227 if (isPending) {
228 return <GenericSkeletonLoader />
229 }
230
231 if (isError) {
232 return <AlertError error={error} subject="Failed to retrieve database triggers" />
233 }
234
235 return (
236 <>
237 <div className="space-y-4">
238 <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
239 <div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
240 <Shortcut
241 id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
242 onTrigger={() => setSchemaSelectorOpen(true)}
243 side="bottom"
244 tooltipOpen={schemaSelectorOpen ? false : undefined}
245 >
246 <SchemaSelector
247 className="w-full lg:w-[180px]"
248 size="tiny"
249 showError={false}
250 selectedSchemaName={selectedSchema}
251 onSelectSchema={setSelectedSchema}
252 open={schemaSelectorOpen}
253 onOpenChange={setSchemaSelectorOpen}
254 />
255 </Shortcut>
256 <InputGroup className="w-full lg:w-52">
257 <InputGroupInput
258 ref={searchInputRef}
259 size="tiny"
260 placeholder="Search for a trigger"
261 value={filterString}
262 onChange={(e) => setFilterString(e.target.value)}
263 onKeyDown={onSearchInputEscape(filterString, setFilterString)}
264 />
265 <InputGroupAddon>
266 <Search />
267 </InputGroupAddon>
268 </InputGroup>
269 <ReportsSelectFilter
270 label="Table"
271 options={tables.map((type) => ({ label: type, value: type }))}
272 value={tablesFilter ?? []}
273 onChange={setTablesFilter}
274 showSearch
275 />
276 </div>
277 <div className="flex items-center gap-2">
278 <DocsButton href={`${DOCS_URL}/guides/database/postgres/triggers`} />
279 {!isSchemaLocked && (
280 <CreateTriggerButtons
281 hasTables={hasTables}
282 canCreateTriggers={canCreateTriggers}
283 selectedSchema={selectedSchema}
284 onCreateTrigger={createTrigger}
285 showPlusIcon={true}
286 />
287 )}
288 </div>
289 </div>
290
291 {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="triggers" />}
292
293 {!isSchemaLocked && (schemaTriggers ?? []).length === 0 ? (
294 <EmptyStatePresentational
295 icon={DatabaseZap}
296 title="Add your first trigger"
297 description="Make your database reactive. Send updates in realtime, call edge functions, or validate data as it comes in."
298 >
299 <CreateTriggerButtons
300 hasTables={hasTables}
301 canCreateTriggers={canCreateTriggers}
302 selectedSchema={selectedSchema}
303 onCreateTrigger={createTrigger}
304 showPlusIcon={false}
305 buttonType="default"
306 />
307 </EmptyStatePresentational>
308 ) : (
309 <div className="w-full overflow-hidden overflow-x-auto">
310 <Card>
311 <Table>
312 <TableHeader>
313 <TableRow>
314 <TableHead key="name">Name</TableHead>
315 <TableHead key="table">Table</TableHead>
316 <TableHead key="function">Function</TableHead>
317 <TableHead key="events">Events</TableHead>
318 <TableHead key="orientation">Orientation</TableHead>
319 <TableHead key="enabled" className="w-20">
320 Enabled
321 </TableHead>
322 <TableHead key="buttons" className="w-1/12"></TableHead>
323 </TableRow>
324 </TableHeader>
325 <TableBody>
326 <TriggerList
327 editTrigger={editTrigger}
328 duplicateTrigger={duplicateTrigger}
329 deleteTrigger={(trigger) => setTriggerToDelete(trigger.id.toString())}
330 />
331 </TableBody>
332 </Table>
333 </Card>
334 </div>
335 )}
336 </div>
337
338 <TriggerSheet
339 selectedTrigger={triggerToEdit || triggerToDuplicate}
340 open={showCreateTriggerForm || !!triggerToEdit || !!triggerToDuplicate}
341 onClose={() => {
342 setShowCreateTriggerForm(false)
343 setTriggerToEdit(null)
344 setTriggerToDuplicate(null)
345 }}
346 isDuplicatingTrigger={!!triggerToDuplicate}
347 />
348
349 <TextConfirmModal
350 variant="warning"
351 visible={!!triggerToDelete}
352 onCancel={() => setTriggerToDelete(null)}
353 onConfirm={() => onDeleteTrigger()}
354 title="Delete this trigger"
355 loading={isDeletingTrigger}
356 confirmLabel={`Delete trigger ${triggerToDelete?.name}`}
357 confirmPlaceholder="Type in name of trigger"
358 confirmString={triggerToDelete?.name ?? ''}
359 text={
360 <>
361 This will delete your trigger called{' '}
362 <span className="text-bold text-foreground">{triggerToDelete?.name}</span> of schema{' '}
363 <span className="text-bold text-foreground">{triggerToDelete?.schema}</span>
364 </>
365 }
366 alert={{
367 title: 'You cannot recover this trigger once deleted.',
368 }}
369 />
370 </>
371 )
372}