From 81fb2baba22bc909d5dc71a73d7675fa1697519d Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 27 Mar 2026 19:35:40 +0100 Subject: [PATCH] backups and triggers --- .../[projectRef]/data/triggers/page.tsx | 5 + .../[projectRef]/settings/backups/page.tsx | 5 + .../Database/Backups/BackupsList.tsx | 5 +- apps/studio/components/v2/BrowserPanel.tsx | 4 + apps/studio/components/v2/Chooser.tsx | 14 +- apps/studio/components/v2/EditorFrame.tsx | 1 + apps/studio/components/v2/HomeView.tsx | 10 +- apps/studio/components/v2/TabBar.tsx | 2 + apps/studio/components/v2/useV2DataCounts.ts | 6 + .../v2/views/V2BackupsScheduled.tsx | 109 ++++ .../components/v2/views/V2TablesList.tsx | 67 ++- .../components/v2/views/V2TriggersList.tsx | 485 ++++++++++++++++++ apps/studio/stores/v2-dashboard.tsx | 2 + 13 files changed, 701 insertions(+), 14 deletions(-) create mode 100644 apps/studio/app/v2/project/[projectRef]/data/triggers/page.tsx create mode 100644 apps/studio/app/v2/project/[projectRef]/settings/backups/page.tsx create mode 100644 apps/studio/components/v2/views/V2BackupsScheduled.tsx create mode 100644 apps/studio/components/v2/views/V2TriggersList.tsx diff --git a/apps/studio/app/v2/project/[projectRef]/data/triggers/page.tsx b/apps/studio/app/v2/project/[projectRef]/data/triggers/page.tsx new file mode 100644 index 00000000000..595c2387219 --- /dev/null +++ b/apps/studio/app/v2/project/[projectRef]/data/triggers/page.tsx @@ -0,0 +1,5 @@ +import { V2TriggersList } from '@/components/v2/views/V2TriggersList' + +export default function DataTriggersPage() { + return +} diff --git a/apps/studio/app/v2/project/[projectRef]/settings/backups/page.tsx b/apps/studio/app/v2/project/[projectRef]/settings/backups/page.tsx new file mode 100644 index 00000000000..4f12813cd22 --- /dev/null +++ b/apps/studio/app/v2/project/[projectRef]/settings/backups/page.tsx @@ -0,0 +1,5 @@ +import { V2BackupsScheduled } from '@/components/v2/views/V2BackupsScheduled' + +export default function SettingsBackupsPage() { + return +} diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index 95c564b7422..1f285f18627 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -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' diff --git a/apps/studio/components/v2/BrowserPanel.tsx b/apps/studio/components/v2/BrowserPanel.tsx index be1438e3552..5730a73df85 100644 --- a/apps/studio/components/v2/BrowserPanel.tsx +++ b/apps/studio/components/v2/BrowserPanel.tsx @@ -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, key: string): numb return counts.tables case 'functions': return counts.functions + case 'triggers': + return counts.triggers case 'types': return counts.types case 'roles': diff --git a/apps/studio/components/v2/Chooser.tsx b/apps/studio/components/v2/Chooser.tsx index 82cfcc72109..4eeadb7d721 100644 --- a/apps/studio/components/v2/Chooser.tsx +++ b/apps/studio/components/v2/Chooser.tsx @@ -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 = { tables: , functions: , + triggers: , types: , roles: , extensions: , @@ -49,7 +51,16 @@ const CATEGORY_ICONS: Record = { 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> = { tables: 'tables', functions: 'functions', + triggers: 'triggers', types: 'types', roles: 'roles', extensions: 'extensions', diff --git a/apps/studio/components/v2/EditorFrame.tsx b/apps/studio/components/v2/EditorFrame.tsx index a2775d49c79..1abdf2d8f3d 100644 --- a/apps/studio/components/v2/EditorFrame.tsx +++ b/apps/studio/components/v2/EditorFrame.tsx @@ -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') || diff --git a/apps/studio/components/v2/HomeView.tsx b/apps/studio/components/v2/HomeView.tsx index 896f08fb2d1..0751a451e5d 100644 --- a/apps/studio/components/v2/HomeView.tsx +++ b/apps/studio/components/v2/HomeView.tsx @@ -283,7 +283,11 @@ export function HomeView() {
@@ -295,7 +299,7 @@ export function HomeView() {
@@ -320,7 +324,7 @@ export function HomeView() {
diff --git a/apps/studio/components/v2/TabBar.tsx b/apps/studio/components/v2/TabBar.tsx index 0589a60b43a..483b5d0b501 100644 --- a/apps/studio/components/v2/TabBar.tsx +++ b/apps/studio/components/v2/TabBar.tsx @@ -11,6 +11,7 @@ import { useV2DashboardStore, type DataTab } from '@/stores/v2-dashboard' const CATEGORY_LABELS: Record = { tables: 'Tables', functions: 'Functions', + triggers: 'Triggers', types: 'Enumerated types', roles: 'Roles', extensions: 'Extensions', @@ -22,6 +23,7 @@ const CATEGORY_LABELS: Record = { buckets: 'Buckets', 'edge-functions': 'Edge functions', channels: 'Channels', + integrations: 'Integrations', } export function TabBar() { diff --git a/apps/studio/components/v2/useV2DataCounts.ts b/apps/studio/components/v2/useV2DataCounts.ts index fa88d87ef19..40f322e76c6 100644 --- a/apps/studio/components/v2/useV2DataCounts.ts +++ b/apps/studio/components/v2/useV2DataCounts.ts @@ -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, diff --git a/apps/studio/components/v2/views/V2BackupsScheduled.tsx b/apps/studio/components/v2/views/V2BackupsScheduled.tsx new file mode 100644 index 00000000000..baefcb21a2b --- /dev/null +++ b/apps/studio/components/v2/views/V2BackupsScheduled.tsx @@ -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 ( +
+
+

Database backups

+

+ Scheduled backups and point-in-time recovery status for this project. +

+
+ + {isOrioleDbInAws ? ( + + + + ) : ( +
+ {isLoading && } + + {isError && ( + + )} + + {isSuccess && ( + <> + {!isPitrEnabled && ( +

+ Projects are backed up daily around midnight of your project’s region and can be + restored at any time. +

+ )} + + {isPitrEnabled && ( + } + title="Point-In-Time-Recovery (PITR) enabled" + description={ +
+ 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.{' '} + + Learn more + +
+ } + /> + )} + + {isPermissionsLoaded && !canReadScheduledBackups ? ( + + ) : ( + + )} + + )} +
+ )} +
+ ) +} diff --git a/apps/studio/components/v2/views/V2TablesList.tsx b/apps/studio/components/v2/views/V2TablesList.tsx index 8d9ddeeabbd..b699c80c147 100644 --- a/apps/studio/components/v2/views/V2TablesList.tsx +++ b/apps/studio/components/v2/views/V2TablesList.tsx @@ -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 = ( - - + { + void setSchemaQuery(v === 'public' ? null : v) + }} + > + - {schemas?.map((s) => ( - - {s.name} + {schemaSelectOptions.map((name) => ( + + {name} ))} diff --git a/apps/studio/components/v2/views/V2TriggersList.tsx b/apps/studio/components/v2/views/V2TriggersList.tsx new file mode 100644 index 00000000000..32301c522e3 --- /dev/null +++ b/apps/studio/components/v2/views/V2TriggersList.tsx @@ -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[] = useMemo( + () => [ + { + id: 'name', + name: 'Name', + width: 200, + minWidth: 120, + sortable: true, + renderCell: (_v, row) => ( + + {row.name} + + ), + }, + { + id: 'table', + name: 'Table', + width: 160, + sortable: true, + renderCell: (_v, row) => + row.table_id ? ( + + {row.table} + + ) : ( + + {row.table} + + ), + }, + { + id: 'function_name', + name: 'Function', + width: 180, + renderCell: (_v, row) => + row.function_name ? ( + + {row.function_name} + + ) : ( + — + ), + }, + { + id: 'events', + name: 'Events', + width: 220, + renderCell: (_v, row) => ( +
+ {row.events.map((event: string) => ( + + {`${row.activation} ${event}`} + + ))} +
+ ), + }, + { + id: 'orientation', + name: 'Orientation', + width: 120, + renderCell: (_v, row) => ( + + {row.orientation} + + ), + }, + { + id: 'enabled_mode', + name: 'Enabled', + width: 88, + align: 'center', + renderCell: (_v, row) => ( +
+ {row.enabled_mode !== 'DISABLED' ? ( + + ) : ( + + )} +
+ ), + }, + ], + [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: , + onClick: (row: PostgresTrigger) => editTrigger(row), + }, + { + id: 'assistant', + label: 'Edit with Assistant', + icon: , + onClick: (row: PostgresTrigger) => openAssistantForTrigger(row), + }, + { + id: 'duplicate', + label: 'Duplicate trigger', + icon: , + onClick: (row: PostgresTrigger) => duplicateTrigger(row), + }, + { + id: 'delete', + label: 'Delete trigger', + icon: , + 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 = ( +
+ +
+ ) + + const toolbarRight = ( +
+ + {!isSchemaLocked && ( + + )} +
+ ) + + return ( +
+ {isSchemaLocked && } + + + 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, + }} + /> + + + + 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{' '} + {triggerToDelete?.name} of schema{' '} + {triggerToDelete?.schema} + + } + alert={{ + title: 'You cannot recover this trigger once deleted.', + }} + /> +
+ ) +} diff --git a/apps/studio/stores/v2-dashboard.tsx b/apps/studio/stores/v2-dashboard.tsx index e2a26005d1e..ce6298f7599 100644 --- a/apps/studio/stores/v2-dashboard.tsx +++ b/apps/studio/stores/v2-dashboard.tsx @@ -37,6 +37,7 @@ export interface RecentItem { export const CATEGORY_DOMAIN: Record = { tables: 'db', functions: 'db', + triggers: 'db', types: 'db', roles: 'db', extensions: 'db', @@ -54,6 +55,7 @@ export const CATEGORY_DOMAIN: Record = { export const CATEGORY_LABELS: Record = { tables: 'Tables', functions: 'Functions', + triggers: 'Triggers', types: 'Enumerated types', roles: 'Roles', extensions: 'Extensions',