mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
backups and triggers
This commit is contained in:
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':
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user