backups and triggers

This commit is contained in:
Francesco Sansalvadore committed 2026-03-27 19:35:40 +01:00
1 parent ad9b83144d
commit 81fb2baba2
13 files changed
+701 -14

No files matched your search

@@ -0,0 +1,5 @@
import { V2TriggersList } from '@/components/v2/views/V2TriggersList'
export default function DataTriggersPage() {
return <V2TriggersList />
}
@@ -0,0 +1,5 @@
import { V2BackupsScheduled } from '@/components/v2/views/V2BackupsScheduled'
export default function SettingsBackupsPage() {
return <V2BackupsScheduled />
}
@@ -2,14 +2,15 @@ import { useParams } from 'common'
import Panel from 'components/ui/Panel'
import { UpgradeToPro } from 'components/ui/UpgradeToPro'
import { useBackupRestoreMutation } from 'data/database/backup-restore-mutation'
import { DatabaseBackup, useBackupsQuery } from 'data/database/backups-query'
import type { DatabaseBackup } from 'data/database/backups-query'
import { useBackupsQuery } from 'data/database/backups-query'
import { useSetProjectStatus } from 'data/projects/project-detail-query'
import dayjs from 'dayjs'
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { Clock } from 'lucide-react'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { TimestampInfo } from 'ui-patterns'
@@ -25,6 +25,7 @@ const DATA_GROUPS = [
items: [
{ href: 'tables', label: 'Tables', countKey: 'tables' },
{ href: 'functions', label: 'Functions', countKey: 'functions' },
{ href: 'triggers', label: 'Triggers', countKey: 'triggers' },
{ href: 'types', label: 'Enumerated types', countKey: 'types' },
{ href: 'roles', label: 'Roles', countKey: 'roles' },
{ href: 'extensions', label: 'Extensions', countKey: 'extensions' },
@@ -108,6 +109,7 @@ const SETTINGS_GROUPS = [
{ href: 'general', label: 'General' },
{ href: 'compute', label: 'Compute & disk' },
{ href: 'network', label: 'Network' },
{ href: 'backups', label: 'Backups' },
{ href: 'api-keys', label: 'API keys' },
{ href: 'preferences', label: 'Preferences' },
],
@@ -138,6 +140,8 @@ function getCount(counts: ReturnType<typeof useV2DataCounts>, key: string): numb
return counts.tables
case 'functions':
return counts.functions
case 'triggers':
return counts.triggers
case 'types':
return counts.types
case 'roles':
+13 -1
View File
@@ -14,6 +14,7 @@ import {
ShieldCheck,
Table2,
Users,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
@@ -32,6 +33,7 @@ import {
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
tables: <Table2 className="size-3" strokeWidth={1.5} />,
functions: <FunctionSquare className="size-3" strokeWidth={1.5} />,
triggers: <Zap className="size-3" strokeWidth={1.5} />,
types: <Layers className="size-3" strokeWidth={1.5} />,
roles: <ShieldCheck className="size-3" strokeWidth={1.5} />,
extensions: <Box className="size-3" strokeWidth={1.5} />,
@@ -49,7 +51,16 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
const CATEGORY_GROUPS: Array<{ label: string; categories: string[] }> = [
{
label: 'Database',
categories: ['tables', 'functions', 'types', 'roles', 'extensions', 'indexes', 'publications'],
categories: [
'tables',
'functions',
'triggers',
'types',
'roles',
'extensions',
'indexes',
'publications',
],
},
{
label: 'Integrations',
@@ -72,6 +83,7 @@ const CATEGORY_GROUPS: Array<{ label: string; categories: string[] }> = [
const COUNT_KEY_MAP: Record<string, keyof ReturnType<typeof useV2DataCounts>> = {
tables: 'tables',
functions: 'functions',
triggers: 'triggers',
types: 'types',
roles: 'roles',
extensions: 'extensions',
@@ -23,6 +23,7 @@ export function EditorFrame({ children }: { children: React.ReactNode }) {
Boolean(
pathname?.includes('/data/tables') ||
pathname?.includes('/data/functions') ||
pathname?.includes('/data/triggers') ||
pathname?.includes('/data/types') ||
pathname?.includes('/data/roles') ||
pathname?.includes('/data/extensions') ||
+7 -3
View File
@@ -283,7 +283,11 @@ export function HomeView() {
<div className="w-full grid grid-cols-2 lg:grid-cols-4 gap-2">
<HomeViewServiceStatusCard projectRef={projectRef} project={project} />
<Link
href={projectRef ? `/project/${projectRef}/database/migrations` : '#'}
href={
projectRef
? `/v2/project/${projectRef}/data/tables?schema=supabase_migrations`
: '#'
}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
@@ -295,7 +299,7 @@ export function HomeView() {
</div>
</Link>
<Link
href={projectRef ? `/project/${projectRef}/database/backups/scheduled` : '#'}
href={projectRef ? `/v2/project/${projectRef}/settings/backups` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
@@ -320,7 +324,7 @@ export function HomeView() {
</div>
</Link>
<Link
href={projectRef ? `/project/${projectRef}/branches` : '#'}
href={projectRef ? `/v2/project/${projectRef}/data/branches` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
+2
View File
@@ -11,6 +11,7 @@ import { useV2DashboardStore, type DataTab } from '@/stores/v2-dashboard'
const CATEGORY_LABELS: Record<string, string> = {
tables: 'Tables',
functions: 'Functions',
triggers: 'Triggers',
types: 'Enumerated types',
roles: 'Roles',
extensions: 'Extensions',
@@ -22,6 +23,7 @@ const CATEGORY_LABELS: Record<string, string> = {
buckets: 'Buckets',
'edge-functions': 'Edge functions',
channels: 'Channels',
integrations: 'Integrations',
}
export function TabBar() {
@@ -5,6 +5,7 @@ import { useInstalledIntegrations } from 'components/interfaces/Integrations/Lan
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { useIndexesQuery } from 'data/database-indexes/indexes-query'
@@ -77,6 +78,10 @@ export function useV2DataCounts(projectRef: string | undefined) {
{ projectRef, connectionString: conn },
{ enabled: canQueryDb }
)
const { data: dbTriggers } = useDatabaseTriggersQuery(
{ projectRef, connectionString: conn },
{ enabled: canQueryDb }
)
const { data: indexes } = useIndexesQuery(
{ projectRef, connectionString: conn, schema: 'public' },
@@ -103,6 +108,7 @@ export function useV2DataCounts(projectRef: string | undefined) {
publications: publications?.length ?? 0,
types: types?.length ?? 0,
functions: dbFunctions?.length ?? 0,
triggers: dbTriggers?.length ?? 0,
indexes: Array.isArray(indexes) ? indexes.length : 0,
providers: Array.isArray(thirdPartyAuth) ? thirdPartyAuth.length : 0,
oauthApps: oauthAppsData?.clients?.length ?? 0,
@@ -0,0 +1,109 @@
'use client'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { BackupsList } from 'components/interfaces/Database/Backups/BackupsList'
import AlertError from 'components/ui/AlertError'
import { DocsButton } from 'components/ui/DocsButton'
import InformationBox from 'components/ui/InformationBox'
import NoPermission from 'components/ui/NoPermission'
import { useBackupsQuery } from 'data/database/backups-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsOrioleDbInAws } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { Info } from 'lucide-react'
import { Admonition } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useV2Params } from '@/app/v2/V2ParamsContext'
/**
* Scheduled database backups (PITR copy, Oriole notice, permissions) — same data path as v1
* `pages/project/[ref]/database/backups/scheduled.tsx`, rendered under v2 project settings.
*/
export function V2BackupsScheduled() {
const { projectRef } = useV2Params()
const {
data: backups,
error,
isPending: isLoading,
isError,
isSuccess,
} = useBackupsQuery({ projectRef })
const isOrioleDbInAws = useIsOrioleDbInAws()
const isPitrEnabled = backups?.pitr_enabled
const { can: canReadScheduledBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.READ,
'back_ups'
)
return (
<div className="flex flex-col gap-4 p-4 max-w-4xl">
<div>
<h1 className="text-lg font-semibold text-foreground">Database backups</h1>
<p className="text-xs text-foreground-lighter mt-0.5">
Scheduled backups and point-in-time recovery status for this project.
</p>
</div>
{isOrioleDbInAws ? (
<Admonition
type="default"
title="Database backups are not available for OrioleDB"
description="OrioleDB is currently in public alpha and projects created are strictly ephemeral with no database backups"
>
<DocsButton abbrev={false} className="mt-2" href={`${DOCS_URL}`} />
</Admonition>
) : (
<div className="flex flex-col gap-y-4">
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError error={error} subject="Failed to retrieve scheduled backups" />
)}
{isSuccess && (
<>
{!isPitrEnabled && (
<p className="text-sm text-foreground-light">
Projects are backed up daily around midnight of your project’s region and can be
restored at any time.
</p>
)}
{isPitrEnabled && (
<InformationBox
hideCollapse
defaultVisibility
icon={<Info strokeWidth={2} />}
title="Point-In-Time-Recovery (PITR) enabled"
description={
<div>
Your project uses PITR and full daily backups are no longer taken. PITR lets
you restore to a specific time (down to the second) within your selected PITR
retention period.{' '}
<a
className="text-brand transition-colors hover:text-brand-600"
href={`${DOCS_URL}/guides/platform/backups`}
>
Learn more
</a>
</div>
}
/>
)}
{isPermissionsLoaded && !canReadScheduledBackups ? (
<NoPermission resourceText="view scheduled backups" />
) : (
<BackupsList />
)}
</>
)}
</div>
)}
</div>
)
}
@@ -7,7 +7,8 @@ import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTablesQuery } from 'data/tables/tables-query'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo } from 'react'
import {
Select_Shadcn_,
SelectContent_Shadcn_,
@@ -21,11 +22,17 @@ import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn } from '@/components/v2/DataTableRenderer'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
/** Ensures migrations schema is selectable even if omitted from catalog listings. */
const MIGRATIONS_SCHEMA = 'supabase_migrations'
export function V2TablesList() {
const router = useRouter()
const { projectRef } = useV2Params()
const openDataTab = useV2DashboardStore((s) => s.openDataTab)
const [schema, setSchema] = useState('public')
const [schemaQuery, setSchemaQuery] = useQueryState(
'schema',
parseAsString.withOptions({ history: 'replace', clearOnDefault: true })
)
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
{ ref: projectRef },
@@ -34,10 +41,49 @@ export function V2TablesList() {
const shouldFetch = Boolean(projectRef) && isValidConnString(project?.connectionString)
const { data: schemas } = useSchemasQuery(
const {
data: schemas,
isSuccess: isSchemasSuccess,
} = useSchemasQuery(
{ projectRef, connectionString: project?.connectionString },
{ enabled: shouldFetch }
)
/** Catalog names plus migrations schema (API often omits internal schemas). */
const schemaNames = useMemo(() => {
const fromApi = (schemas ?? []).map((s) => s.name)
const set = new Set(fromApi)
set.add(MIGRATIONS_SCHEMA)
return Array.from(set).sort((a, b) => a.localeCompare(b))
}, [schemas])
const schemasCatalogReady = shouldFetch && isSchemasSuccess
/**
* Until the schema catalog has loaded, keep the URL param so deep links like ?schema=auth are
* not cleared when `schemaNames` is still only the bootstrap set (e.g. supabase_migrations).
*/
const schema = useMemo(() => {
if (!schemaQuery) return 'public'
if (!shouldFetch) return 'public'
if (!schemasCatalogReady) return schemaQuery
return schemaNames.includes(schemaQuery) ? schemaQuery : 'public'
}, [schemaQuery, shouldFetch, schemasCatalogReady, schemaNames])
useEffect(() => {
if (!schemaQuery || !schemasCatalogReady) return
if (!schemaNames.includes(schemaQuery)) {
void setSchemaQuery(null)
}
}, [schemaQuery, schemasCatalogReady, schemaNames, setSchemaQuery])
/** Include current URL schema in the dropdown before/while catalog loads, or Radix Select breaks. */
const schemaSelectOptions = useMemo(() => {
if (schemaQuery && !schemaNames.includes(schemaQuery)) {
return [...schemaNames, schemaQuery].sort((a, b) => a.localeCompare(b))
}
return schemaNames
}, [schemaNames, schemaQuery])
const {
data: tables,
isLoading: isTablesLoading,
@@ -114,14 +160,19 @@ export function V2TablesList() {
)
const schemaSelector = (
<Select_Shadcn_ value={schema} onValueChange={setSchema}>
<SelectTrigger_Shadcn_ className="h-8 w-[140px] text-xs">
<Select_Shadcn_
value={schema}
onValueChange={(v) => {
void setSchemaQuery(v === 'public' ? null : v)
}}
>
<SelectTrigger_Shadcn_ className="h-8 min-w-[140px] max-w-[200px] w-[min(100%,200px)] text-xs">
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{schemas?.map((s) => (
<SelectItem_Shadcn_ key={s.name} value={s.name}>
{s.name}
{schemaSelectOptions.map((name) => (
<SelectItem_Shadcn_ key={name} value={name}>
{name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
@@ -0,0 +1,485 @@
'use client'
import type { PostgresTrigger } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings'
import { ProtectedSchemaWarning } from 'components/interfaces/Database/ProtectedSchemaWarning'
import { CreateTriggerButtons } from 'components/interfaces/Database/Triggers/TriggersList/CreateTriggerButtons'
import { TriggerSheet } from 'components/interfaces/Database/Triggers/TriggerSheet'
import { generateTriggerCreateSQL } from 'components/interfaces/Database/Triggers/TriggersList/TriggerList.utils'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import SchemaSelector from 'components/ui/SchemaSelector'
import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseTriggerDeleteMutation } from 'data/database-triggers/database-trigger-delete-mutation'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
import { isValidConnString } from 'data/fetchers'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useIsProtectedSchema, useProtectedSchemas } from 'hooks/useProtectedSchemas'
import { DOCS_URL } from 'lib/constants'
import { Check, CopyPlus, Pencil, Sparkles, Trash2, X } from 'lucide-react'
import Link from 'next/link'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo } from 'react'
import { toast } from 'sonner'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useEditorPanelStateSnapshot } from 'state/editor-panel-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { Badge } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn, FilterDefinition } from '@/components/v2/DataTableRenderer'
import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams'
export function V2TriggersList() {
const { projectRef } = useV2Params()
const base = projectRef ? `/v2/project/${projectRef}` : ''
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
const { data: protectedSchemas } = useProtectedSchemas()
const { openSidebar } = useSidebarManagerSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const {
templates: editorPanelTemplates,
setValue: setEditorPanelValue,
setTemplates: setEditorPanelTemplates,
setInitialPrompt: setEditorPanelInitialPrompt,
} = useEditorPanelStateSnapshot()
const {
isCreating,
setIsCreating,
editingId,
setEditingId,
duplicatingId,
setDuplicatingId,
closePanels,
} = useEntityPanelParams()
const [deleteId, setDeleteId] = useQueryState(
'delete',
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
)
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const shouldFetch = Boolean(projectRef) && isValidConnString(project?.connectionString)
const { data: tables = [] } = useTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{ enabled: shouldFetch }
)
const hasTables =
tables.filter((t) => !protectedSchemas?.find((s) => s.name === t.schema)).length > 0
const {
data: triggers = [],
error,
isPending: isTriggersPending,
isSuccess,
isError,
} = useDatabaseTriggersQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{ enabled: shouldFetch }
)
const { can: canWriteTriggers } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'triggers'
)
const schemaTriggers = useMemo(
() => triggers.filter((t) => t.schema === selectedSchema),
[triggers, selectedSchema]
)
const tableFilterOptions = useMemo(() => {
const names = Array.from(new Set(schemaTriggers.map((t) => t.table))).sort()
return names.map((name) => ({ value: name, label: name }))
}, [schemaTriggers])
const filters: FilterDefinition[] = useMemo(() => {
const list: FilterDefinition[] = [
{
id: 'search',
label: 'Search',
type: 'search',
placeholder: 'Search triggers…',
},
]
if (tableFilterOptions.length > 0) {
list.push({
id: 'table',
label: 'Table',
type: 'multi-select',
options: tableFilterOptions,
})
}
return list
}, [tableFilterOptions])
const triggerToEdit = triggers.find((t) => t.id.toString() === editingId)
const triggerToDuplicate = triggers.find((t) => t.id.toString() === duplicatingId)
const triggerToDelete = triggers.find((t) => t.id.toString() === deleteId)
const {
mutate: deleteDatabaseTrigger,
isPending: isDeletingTrigger,
isSuccess: isSuccessDelete,
} = useDatabaseTriggerDeleteMutation({
onSuccess: (_, variables) => {
toast.success(`Successfully removed ${variables.trigger.name}`)
void setDeleteId(null)
},
})
const columns: DataTableColumn<PostgresTrigger>[] = useMemo(
() => [
{
id: 'name',
name: 'Name',
width: 200,
minWidth: 120,
sortable: true,
renderCell: (_v, row) => (
<span className="font-mono text-xs text-foreground truncate" title={row.name}>
{row.name}
</span>
),
},
{
id: 'table',
name: 'Table',
width: 160,
sortable: true,
renderCell: (_v, row) =>
row.table_id ? (
<Link
href={`${base}/data/tables/${row.table_id}`}
className="text-link-table-cell font-mono text-xs text-foreground-light truncate block max-w-full"
>
{row.table}
</Link>
) : (
<span className="font-mono text-xs truncate" title={row.table}>
{row.table}
</span>
),
},
{
id: 'function_name',
name: 'Function',
width: 180,
renderCell: (_v, row) =>
row.function_name ? (
<Link
href={`${base}/data/functions?search=${encodeURIComponent(row.function_name)}`}
className="text-link-table-cell font-mono text-xs text-foreground-light truncate block max-w-full"
>
{row.function_name}
</Link>
) : (
<span className="text-foreground-lighter">—</span>
),
},
{
id: 'events',
name: 'Events',
width: 220,
renderCell: (_v, row) => (
<div className="flex flex-wrap gap-1">
{row.events.map((event: string) => (
<Badge key={`${row.id}-${event}`} className="text-[10px]">
{`${row.activation} ${event}`}
</Badge>
))}
</div>
),
},
{
id: 'orientation',
name: 'Orientation',
width: 120,
renderCell: (_v, row) => (
<span className="text-xs truncate" title={row.orientation}>
{row.orientation}
</span>
),
},
{
id: 'enabled_mode',
name: 'Enabled',
width: 88,
align: 'center',
renderCell: (_v, row) => (
<div className="flex justify-center">
{row.enabled_mode !== 'DISABLED' ? (
<Check strokeWidth={2} className="text-brand size-4" />
) : (
<X strokeWidth={2} className="text-foreground-lighter size-4" />
)}
</div>
),
},
],
[base]
)
const createTrigger = useCallback(() => {
void setDuplicatingId(null)
if (isInlineEditorEnabled) {
setEditorPanelInitialPrompt('Create a new database trigger that...')
setEditorPanelValue(`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 {
void setIsCreating(true)
}
}, [
isInlineEditorEnabled,
setDuplicatingId,
setEditorPanelInitialPrompt,
setEditorPanelValue,
editorPanelTemplates.length,
setEditorPanelTemplates,
openSidebar,
setIsCreating,
])
const editTrigger = useCallback(
(trigger: PostgresTrigger) => {
void setDuplicatingId(null)
if (isInlineEditorEnabled) {
setEditorPanelValue(generateTriggerCreateSQL(trigger))
setEditorPanelTemplates([])
openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
} else {
void setEditingId(trigger.id.toString())
}
},
[
isInlineEditorEnabled,
setDuplicatingId,
setEditorPanelValue,
setEditorPanelTemplates,
openSidebar,
setEditingId,
]
)
const duplicateTrigger = useCallback(
(trigger: PostgresTrigger) => {
if (isInlineEditorEnabled) {
const dup = { ...trigger, name: `${trigger.name}_duplicate` }
setEditorPanelValue(generateTriggerCreateSQL(dup))
setEditorPanelTemplates([])
openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
} else {
void setDuplicatingId(trigger.id.toString())
}
},
[isInlineEditorEnabled, setEditorPanelValue, setEditorPanelTemplates, openSidebar, setDuplicatingId]
)
const openAssistantForTrigger = useCallback(
(trigger: PostgresTrigger) => {
const sql = generateTriggerCreateSQL(trigger)
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({
name: `Update trigger ${trigger.name}`,
initialInput: `Update this trigger which exists on the ${trigger.schema}.${trigger.table} table to...`,
suggestions: {
title:
'I can help you make a change to this trigger, here are a few example prompts to get you started:',
prompts: [
{ label: 'Rename Trigger', description: 'Rename this trigger to ...' },
{ label: 'Change Events', description: 'Change the events this trigger responds to ...' },
{
label: 'Modify Timing',
description: 'Modify this trigger to run after instead of before ...',
},
],
},
sqlSnippets: [sql],
})
},
[aiSnap, openSidebar]
)
const rowActions = useMemo(() => {
if (isSchemaLocked || !canWriteTriggers) return undefined
return [
{
id: 'edit',
label: 'Edit trigger',
icon: <Pencil size={12} />,
onClick: (row: PostgresTrigger) => editTrigger(row),
},
{
id: 'assistant',
label: 'Edit with Assistant',
icon: <Sparkles size={12} />,
onClick: (row: PostgresTrigger) => openAssistantForTrigger(row),
},
{
id: 'duplicate',
label: 'Duplicate trigger',
icon: <CopyPlus size={12} />,
onClick: (row: PostgresTrigger) => duplicateTrigger(row),
},
{
id: 'delete',
label: 'Delete trigger',
icon: <Trash2 size={12} />,
variant: 'danger' as const,
onClick: (row: PostgresTrigger) => void setDeleteId(row.id.toString()),
},
]
}, [
isSchemaLocked,
canWriteTriggers,
editTrigger,
duplicateTrigger,
openAssistantForTrigger,
setDeleteId,
])
useEffect(() => {
if (isSuccess && !!editingId && !triggerToEdit) {
toast('Trigger not found')
void setEditingId(null)
}
}, [isSuccess, editingId, triggerToEdit, setEditingId])
useEffect(() => {
if (isSuccess && !!duplicatingId && !triggerToDuplicate) {
toast('Trigger not found')
void setDuplicatingId(null)
}
}, [isSuccess, duplicatingId, triggerToDuplicate, setDuplicatingId])
useEffect(() => {
if (isSuccess && !!deleteId && !triggerToDelete && !isSuccessDelete) {
toast('Trigger not found')
void setDeleteId(null)
}
}, [isSuccess, deleteId, triggerToDelete, isSuccessDelete, setDeleteId])
const onDeleteConfirm = () => {
if (!projectRef || !project?.connectionString || !triggerToDelete) return
deleteDatabaseTrigger({
projectRef,
connectionString: project.connectionString,
trigger: triggerToDelete,
})
}
const toolbarLeft = (
<div className="flex flex-wrap items-center gap-2 min-w-0">
<SchemaSelector
className="w-[min(100%,180px)] shrink-0"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
</div>
)
const toolbarRight = (
<div className="flex items-center gap-2 shrink-0">
<DocsButton href={`${DOCS_URL}/guides/database/postgres/triggers`} />
{!isSchemaLocked && (
<CreateTriggerButtons
hasTables={hasTables}
canCreateTriggers={canWriteTriggers}
selectedSchema={selectedSchema}
onCreateTrigger={createTrigger}
showPlusIcon
/>
)}
</div>
)
return (
<div className="flex h-full min-h-0 flex-col gap-3 p-3">
{isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="triggers" />}
<DataTableRenderer<PostgresTrigger>
className="min-h-0 flex-1"
columns={columns}
rows={schemaTriggers}
rowKey={(row) => String(row.id)}
isLoading={isProjectPending || (shouldFetch && isTriggersPending)}
error={isError ? (error as Error) : null}
filters={filters}
toolbarLeft={toolbarLeft}
toolbarRight={toolbarRight}
rowActions={rowActions}
onRowDoubleClick={
!isSchemaLocked && canWriteTriggers ? (row) => editTrigger(row) : undefined
}
emptyState={{
title: isSchemaLocked ? 'No triggers' : 'Add your first trigger',
description: isSchemaLocked
? `Triggers in schema "${selectedSchema}" cannot be managed here.`
: 'Make your database reactive: realtime updates, edge functions, or validation as data changes.',
action:
!isSchemaLocked && hasTables && canWriteTriggers
? { label: 'New trigger', onClick: createTrigger }
: undefined,
}}
/>
<TriggerSheet
selectedTrigger={triggerToEdit || triggerToDuplicate}
open={isCreating || !!triggerToEdit || !!triggerToDuplicate}
onClose={closePanels}
isDuplicatingTrigger={!!triggerToDuplicate}
/>
<TextConfirmModal
variant="warning"
visible={!!triggerToDelete}
onCancel={() => void setDeleteId(null)}
onConfirm={onDeleteConfirm}
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.',
}}
/>
</div>
)
}
+2
View File
@@ -37,6 +37,7 @@ export interface RecentItem {
export const CATEGORY_DOMAIN: Record<string, DataTabDomain> = {
tables: 'db',
functions: 'db',
triggers: 'db',
types: 'db',
roles: 'db',
extensions: 'db',
@@ -54,6 +55,7 @@ export const CATEGORY_DOMAIN: Record<string, DataTabDomain> = {
export const CATEGORY_LABELS: Record<string, string> = {
tables: 'Tables',
functions: 'Functions',
triggers: 'Triggers',
types: 'Enumerated types',
roles: 'Roles',
extensions: 'Extensions',