TriggerList.tsx261 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { includes, sortBy } from 'lodash'
4import { Check, Copy, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react'
5import Link from 'next/link'
6import { parseAsJson, parseAsString, useQueryState } from 'nuqs'
7import {
8 Badge,
9 Button,
10 DropdownMenu,
11 DropdownMenuContent,
12 DropdownMenuItem,
13 DropdownMenuSeparator,
14 DropdownMenuTrigger,
15 TableCell,
16 TableRow,
17} from 'ui'
18
19import {
20 generateTriggerCreateSQL,
21 getDatabaseFunctionsHref,
22 type PostgresTrigger,
23} from './TriggerList.utils'
24import { selectFilterSchema } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
25import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
26import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
27import { useDatabaseTriggersQuery } from '@/data/database-triggers/database-triggers-query'
28import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
29import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
30import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
31import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
32import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
33import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
34
35interface TriggerListProps {
36 editTrigger: (trigger: PostgresTrigger) => void
37 duplicateTrigger: (trigger: PostgresTrigger) => void
38 deleteTrigger: (trigger: PostgresTrigger) => void
39}
40
41export const TriggerList = ({ editTrigger, duplicateTrigger, deleteTrigger }: TriggerListProps) => {
42 const { ref: projectRef } = useParams()
43 const { data: project } = useSelectedProjectQuery()
44 const aiSnap = useAiAssistantStateSnapshot()
45 const { openSidebar } = useSidebarManagerSnapshot()
46
47 const { can: canUpdateTriggers } = useAsyncCheckPermissions(
48 PermissionAction.TENANT_SQL_ADMIN_WRITE,
49 'triggers'
50 )
51
52 const { selectedSchema: schema } = useQuerySchemaState()
53 const { isSchemaLocked: isLocked } = useIsProtectedSchema({ schema })
54 const [filterString] = useQueryState('search', parseAsString.withDefault(''))
55 const [tablesFilter] = useQueryState(
56 'tables',
57 parseAsJson(selectFilterSchema.parse).withDefault([])
58 )
59
60 const { data: triggers } = useDatabaseTriggersQuery({
61 projectRef: project?.ref,
62 connectionString: project?.connectionString,
63 })
64 const filteredTriggers = (triggers ?? []).filter((x) => {
65 const search = filterString?.toLowerCase()
66 const matchesSearch =
67 !search ||
68 x.name.toLowerCase().includes(search) ||
69 (!!x.function_name && includes(x.function_name.toLowerCase(), search))
70 const matchesTables = !tablesFilter?.length || tablesFilter.includes(x.table)
71 return matchesSearch && matchesTables
72 })
73 const _triggers = sortBy(
74 filteredTriggers.filter((x) => x.schema == schema),
75 (trigger) => trigger.name.toLocaleLowerCase()
76 )
77
78 if (_triggers.length === 0 && filterString.length === 0) {
79 return (
80 <TableRow key={schema}>
81 <TableCell colSpan={7}>
82 <p className="text-sm text-foreground">No triggers created yet</p>
83 <p className="text-sm text-foreground-light">
84 There are no triggers found in the schema "{schema}"
85 </p>
86 </TableCell>
87 </TableRow>
88 )
89 }
90
91 if (_triggers.length === 0 && filterString.length > 0) {
92 return (
93 <TableRow key={schema}>
94 <TableCell colSpan={7}>
95 <p className="text-sm text-foreground">No results found</p>
96 <p className="text-sm text-foreground-light">
97 Your search for "{filterString}" did not return any results
98 </p>
99 </TableCell>
100 </TableRow>
101 )
102 }
103
104 return (
105 <>
106 {_triggers.map((x) => (
107 <TableRow key={x.id}>
108 <TableCell className="space-x-2">
109 <Button
110 type="text"
111 disabled={isLocked || !canUpdateTriggers}
112 onClick={() => editTrigger(x)}
113 title={x.name}
114 className="text-link-table-cell text-left text-sm disabled:opacity-90 disabled:no-underline min-w-0 p-0 hover:bg-transparent font-medium max-w-48 title"
115 >
116 {x.name}
117 </Button>
118 </TableCell>
119
120 <TableCell className="break-all">
121 {x.table_id ? (
122 <Link
123 href={`/project/${projectRef}/editor/${x.table_id}`}
124 className="text-link-table-cell block max-w-40 text-foreground-light"
125 >
126 {x.table}
127 </Link>
128 ) : (
129 <p title={x.table} className="truncate">
130 {x.table}
131 </p>
132 )}
133 </TableCell>
134
135 <TableCell className="space-x-2">
136 {x.function_name ? (
137 <Link
138 href={getDatabaseFunctionsHref(projectRef, x.function_schema, x.function_name)}
139 className="text-link-table-cell block max-w-40 text-foreground-light"
140 >
141 {x.function_name}
142 </Link>
143 ) : (
144 <p className="truncate text-foreground-light">-</p>
145 )}
146 </TableCell>
147
148 <TableCell>
149 <div className="flex gap-2 flex-wrap">
150 {x.events.map((event: string) => (
151 <Badge key={event}>{`${x.activation} ${event}`}</Badge>
152 ))}
153 </div>
154 </TableCell>
155
156 <TableCell className="space-x-2">
157 <p title={x.orientation} className="truncate">
158 {x.orientation}
159 </p>
160 </TableCell>
161
162 <TableCell>
163 <div className="flex items-center justify-center">
164 {x.enabled_mode !== 'DISABLED' ? (
165 <Check strokeWidth={2} className="text-brand" />
166 ) : (
167 <X strokeWidth={2} />
168 )}
169 </div>
170 </TableCell>
171
172 <TableCell className="text-right">
173 {!isLocked && (
174 <div className="flex items-center justify-end">
175 {canUpdateTriggers ? (
176 <DropdownMenu>
177 <DropdownMenuTrigger asChild>
178 <Button
179 aria-label="More options"
180 type="default"
181 className="px-1"
182 icon={<MoreVertical />}
183 />
184 </DropdownMenuTrigger>
185 <DropdownMenuContent side="bottom" align="end" className="w-52">
186 <DropdownMenuItem
187 className="space-x-2"
188 onClick={() => {
189 editTrigger(x)
190 }}
191 >
192 <Edit2 size={14} />
193 <p>Edit trigger</p>
194 </DropdownMenuItem>
195 <DropdownMenuItem
196 className="space-x-2"
197 onClick={() => {
198 const sql = generateTriggerCreateSQL(x)
199 openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
200 aiSnap.newChat({
201 name: `Update trigger ${x.name}`,
202 initialInput: `Update this trigger which exists on the ${x.schema}.${x.table} table to...`,
203 suggestions: {
204 title:
205 'I can help you make a change to this trigger, here are a few example prompts to get you started:',
206 prompts: [
207 {
208 label: 'Rename Trigger',
209 description: 'Rename this trigger to ...',
210 },
211 {
212 label: 'Change Events',
213 description: 'Change the events this trigger responds to ...',
214 },
215 {
216 label: 'Modify Timing',
217 description:
218 'Modify this trigger to run after instead of before ...',
219 },
220 ],
221 },
222 sqlSnippets: [sql],
223 })
224 }}
225 >
226 <Edit size={14} />
227 <p>Edit with Assistant</p>
228 </DropdownMenuItem>
229 <DropdownMenuItem className="space-x-2" onClick={() => duplicateTrigger(x)}>
230 <Copy size={14} />
231 <p>Duplicate trigger</p>
232 </DropdownMenuItem>
233 <DropdownMenuSeparator />
234 <DropdownMenuItem className="space-x-2" onClick={() => deleteTrigger(x)}>
235 <Trash stroke="red" size={14} />
236 <p>Delete trigger</p>
237 </DropdownMenuItem>
238 </DropdownMenuContent>
239 </DropdownMenu>
240 ) : (
241 <ButtonTooltip
242 disabled
243 type="default"
244 className="px-1"
245 icon={<MoreVertical />}
246 tooltip={{
247 content: {
248 side: 'bottom',
249 text: 'You need additional permissions to update triggers',
250 },
251 }}
252 />
253 )}
254 </div>
255 )}
256 </TableCell>
257 </TableRow>
258 ))}
259 </>
260 )
261}