EventTriggersList.tsx 11 KB

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