TriggersList.tsx 13 KB

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