| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- import { safeSql } from '@supabase/pg-meta'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { DatabaseZap, Search } from 'lucide-react'
- import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
- import { useEffect, useRef, useState } from 'react'
- import { toast } from 'sonner'
- import {
- Card,
- InputGroup,
- InputGroupAddon,
- InputGroupInput,
- Table,
- TableBody,
- TableHead,
- TableHeader,
- TableRow,
- } from 'ui'
- import { EmptyStatePresentational } from 'ui-patterns'
- import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
- import { CreateTriggerButtons } from './CreateTriggerButtons'
- import { TriggerList } from './TriggerList'
- import { generateTriggerCreateSQL, type PostgresTrigger } from './TriggerList.utils'
- import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
- import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
- import { TriggerSheet } from '@/components/interfaces/Database/Triggers/TriggerSheet'
- import {
- ReportsSelectFilter,
- selectFilterSchema,
- } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import AlertError from '@/components/ui/AlertError'
- import { DocsButton } from '@/components/ui/DocsButton'
- import SchemaSelector from '@/components/ui/SchemaSelector'
- import { Shortcut } from '@/components/ui/Shortcut'
- import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
- import { useDatabaseTriggerDeleteMutation } from '@/data/database-triggers/database-trigger-delete-mutation'
- import { useDatabaseTriggersQuery } from '@/data/database-triggers/database-triggers-query'
- import { useTablesQuery } from '@/data/tables/tables-query'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useIsProtectedSchema, useProtectedSchemas } from '@/hooks/useProtectedSchemas'
- import { DOCS_URL } from '@/lib/constants'
- import { onSearchInputEscape } from '@/lib/keyboard'
- import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useShortcut } from '@/state/shortcuts/useShortcut'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- export const TriggersList = () => {
- const { data: project } = useSelectedProjectQuery()
- const { openSidebar } = useSidebarManagerSnapshot()
- const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
- const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
- const [tablesFilter, setTablesFilter] = useQueryState(
- 'tables',
- parseAsJson(selectFilterSchema.parse).withDefault([])
- )
- const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
- const searchInputRef = useRef<HTMLInputElement>(null)
- const isInlineEditorEnabled = useIsInlineEditorEnabled()
- const {
- templates: editorPanelTemplates,
- setValue: setEditorPanelValue,
- setTemplates: setEditorPanelTemplates,
- setInitialPrompt: setEditorPanelInitialPrompt,
- } = useEditorPanelStateSnapshot()
- const { data: protectedSchemas } = useProtectedSchemas()
- const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
- const { data = [] } = useTablesQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const hasTables =
- data.filter((a) => !protectedSchemas.find((s) => s.name === a.schema)).length > 0
- const {
- data: triggers = [],
- error,
- isPending,
- isSuccess,
- isError,
- } = useDatabaseTriggersQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const { can: canCreateTriggers } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- 'triggers'
- )
- const [showCreateTriggerForm, setShowCreateTriggerForm] = useQueryState(
- 'new',
- parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
- )
- const [triggerIdToEdit, setTriggerToEdit] = useQueryState('edit', parseAsString)
- const triggerToEdit = triggers?.find((fn) => fn.id.toString() === triggerIdToEdit)
- const [triggerIdToDuplicate, setTriggerToDuplicate] = useQueryState('duplicate', parseAsString)
- const triggerToDuplicate = triggers?.find((fn) => fn.id.toString() === triggerIdToDuplicate)
- const [triggerIdToDelete, setTriggerToDelete] = useQueryState('delete', parseAsString)
- const triggerToDelete = triggers?.find((fn) => fn.id.toString() === triggerIdToDelete)
- const {
- mutate: deleteDatabaseTrigger,
- isPending: isDeletingTrigger,
- isSuccess: isSuccessDelete,
- } = useDatabaseTriggerDeleteMutation({
- onSuccess: (_, variables) => {
- toast.success(`Successfully removed ${variables.trigger.name}`)
- setTriggerToDelete(null)
- },
- })
- const createTrigger = () => {
- setTriggerToDuplicate(null)
- if (isInlineEditorEnabled) {
- setEditorPanelInitialPrompt('Create a new database trigger that...')
- setEditorPanelValue(
- safeSql`create trigger trigger_name
- after insert or update or delete on table_name
- for each row
- execute function function_name();`
- )
- if (editorPanelTemplates.length > 0) {
- setEditorPanelTemplates([])
- }
- openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- } else {
- setShowCreateTriggerForm(true)
- }
- }
- const editTrigger = (trigger: PostgresTrigger) => {
- setTriggerToDuplicate(null)
- if (isInlineEditorEnabled) {
- setEditorPanelValue(generateTriggerCreateSQL(trigger))
- setEditorPanelTemplates([])
- openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- } else {
- setTriggerToEdit(trigger.id.toString())
- }
- }
- const duplicateTrigger = (trigger: PostgresTrigger) => {
- if (isInlineEditorEnabled) {
- const dupTrigger = {
- ...trigger,
- name: `${trigger.name}_duplicate`,
- }
- setEditorPanelValue(generateTriggerCreateSQL(dupTrigger))
- setEditorPanelTemplates([])
- openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- } else {
- setTriggerToDuplicate(trigger.id.toString())
- }
- }
- const onDeleteTrigger = () => {
- if (!project) return console.error('Project is required')
- if (!triggerToDelete) return console.error('Trigger ID is required')
- deleteDatabaseTrigger({
- projectRef: project.ref,
- connectionString: project.connectionString,
- trigger: triggerToDelete,
- })
- }
- const schemaTriggers = triggers.filter((x) => x.schema === selectedSchema)
- const tables = Array.from(new Set(schemaTriggers.map((x) => x.table))).sort()
- const canAddTriggers = canCreateTriggers && !isSchemaLocked && hasTables
- useShortcut(
- SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
- () => {
- searchInputRef.current?.focus()
- searchInputRef.current?.select()
- },
- { label: 'Search triggers' }
- )
- useShortcut(
- SHORTCUT_IDS.LIST_PAGE_NEW_ITEM,
- () => {
- createTrigger()
- },
- { enabled: canAddTriggers, label: 'Create new trigger' }
- )
- useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
- setFilterString('')
- setTablesFilter([])
- })
- useEffect(() => {
- if (isSuccess && !!triggerIdToEdit && !triggerToEdit) {
- toast('Trigger not found')
- setTriggerToEdit(null)
- }
- }, [isSuccess, setTriggerToEdit, triggerIdToEdit, triggerToEdit])
- useEffect(() => {
- if (isSuccess && !!triggerIdToDuplicate && !triggerToDuplicate) {
- toast('Trigger not found')
- setTriggerToDuplicate(null)
- }
- }, [isSuccess, triggerIdToDuplicate, triggerToDuplicate, setTriggerToDuplicate])
- useEffect(() => {
- if (isSuccess && !!triggerIdToDelete && !triggerToDelete && !isSuccessDelete) {
- toast('Trigger not found')
- setTriggerToDelete(null)
- }
- }, [isSuccess, triggerIdToDelete, triggerToDelete, isSuccessDelete, setTriggerToDelete])
- if (isPending) {
- return <GenericSkeletonLoader />
- }
- if (isError) {
- return <AlertError error={error} subject="Failed to retrieve database triggers" />
- }
- return (
- <>
- <div className="space-y-4">
- <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
- <div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
- onTrigger={() => setSchemaSelectorOpen(true)}
- side="bottom"
- tooltipOpen={schemaSelectorOpen ? false : undefined}
- >
- <SchemaSelector
- className="w-full lg:w-[180px]"
- size="tiny"
- showError={false}
- selectedSchemaName={selectedSchema}
- onSelectSchema={setSelectedSchema}
- open={schemaSelectorOpen}
- onOpenChange={setSchemaSelectorOpen}
- />
- </Shortcut>
- <InputGroup className="w-full lg:w-52">
- <InputGroupInput
- ref={searchInputRef}
- size="tiny"
- placeholder="Search for a trigger"
- value={filterString}
- onChange={(e) => setFilterString(e.target.value)}
- onKeyDown={onSearchInputEscape(filterString, setFilterString)}
- />
- <InputGroupAddon>
- <Search />
- </InputGroupAddon>
- </InputGroup>
- <ReportsSelectFilter
- label="Table"
- options={tables.map((type) => ({ label: type, value: type }))}
- value={tablesFilter ?? []}
- onChange={setTablesFilter}
- showSearch
- />
- </div>
- <div className="flex items-center gap-2">
- <DocsButton href={`${DOCS_URL}/guides/database/postgres/triggers`} />
- {!isSchemaLocked && (
- <CreateTriggerButtons
- hasTables={hasTables}
- canCreateTriggers={canCreateTriggers}
- selectedSchema={selectedSchema}
- onCreateTrigger={createTrigger}
- showPlusIcon={true}
- />
- )}
- </div>
- </div>
- {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="triggers" />}
- {!isSchemaLocked && (schemaTriggers ?? []).length === 0 ? (
- <EmptyStatePresentational
- icon={DatabaseZap}
- title="Add your first trigger"
- description="Make your database reactive. Send updates in realtime, call edge functions, or validate data as it comes in."
- >
- <CreateTriggerButtons
- hasTables={hasTables}
- canCreateTriggers={canCreateTriggers}
- selectedSchema={selectedSchema}
- onCreateTrigger={createTrigger}
- showPlusIcon={false}
- buttonType="default"
- />
- </EmptyStatePresentational>
- ) : (
- <div className="w-full overflow-hidden overflow-x-auto">
- <Card>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead key="name">Name</TableHead>
- <TableHead key="table">Table</TableHead>
- <TableHead key="function">Function</TableHead>
- <TableHead key="events">Events</TableHead>
- <TableHead key="orientation">Orientation</TableHead>
- <TableHead key="enabled" className="w-20">
- Enabled
- </TableHead>
- <TableHead key="buttons" className="w-1/12"></TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- <TriggerList
- editTrigger={editTrigger}
- duplicateTrigger={duplicateTrigger}
- deleteTrigger={(trigger) => setTriggerToDelete(trigger.id.toString())}
- />
- </TableBody>
- </Table>
- </Card>
- </div>
- )}
- </div>
- <TriggerSheet
- selectedTrigger={triggerToEdit || triggerToDuplicate}
- open={showCreateTriggerForm || !!triggerToEdit || !!triggerToDuplicate}
- onClose={() => {
- setShowCreateTriggerForm(false)
- setTriggerToEdit(null)
- setTriggerToDuplicate(null)
- }}
- isDuplicatingTrigger={!!triggerToDuplicate}
- />
- <TextConfirmModal
- variant="warning"
- visible={!!triggerToDelete}
- onCancel={() => setTriggerToDelete(null)}
- onConfirm={() => onDeleteTrigger()}
- title="Delete this trigger"
- loading={isDeletingTrigger}
- confirmLabel={`Delete trigger ${triggerToDelete?.name}`}
- confirmPlaceholder="Type in name of trigger"
- confirmString={triggerToDelete?.name ?? ''}
- text={
- <>
- This will delete your trigger called{' '}
- <span className="text-bold text-foreground">{triggerToDelete?.name}</span> of schema{' '}
- <span className="text-bold text-foreground">{triggerToDelete?.schema}</span>
- </>
- }
- alert={{
- title: 'You cannot recover this trigger once deleted.',
- }}
- />
- </>
- )
- }
|