Files
Jordi Enric f0952fdef8 fix(studio): delete only the selected foreign server FE-4462 (#50785)
## Problem

The dashboard lists one row per foreign server, but deleting a row
dropped its foreign data wrapper with CASCADE. When multiple servers
shared a wrapper, deleting one removed all of them.

## Fix

Drop the selected server and its foreign tables. Remove the underlying
wrapper and Vault secret only when no servers still use it. Edits to a
shared wrapper now stop before making changes because the existing edit
flow recreates the underlying wrapper.

## How to test

1. Configure two BigQuery foreign servers that use the same foreign data
wrapper. Delete one from the dashboard.
2. Confirm the other server and its foreign tables still exist and work.
3. Delete the remaining server. Confirm the foreign data wrapper and its
Vault secret are removed.
4. Attempt to edit one of two servers sharing a wrapper. Confirm the
edit fails without removing either server.

Focused pg-meta tests and typecheck pass.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Shared connections are identified in the integrations list, with
guidance for editing them in the SQL Editor. Editing is disabled when a
wrapper is shared, with an explanation shown.
* Deleting a connection removes its foreign tables and removes the
wrapper and Vault secret only when no other connection uses them.
* **Bug Fixes**
* Connection deletion verifies that the selected server still belongs to
the wrapper and reports failures using connection-focused wording.
* Attempts to edit a wrapper used by another connection are blocked with
a clear explanation.
* Connection deletion and confirmation messages now consistently refer
to deleting a connection.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-25 16:49:01 +02:00

155 lines
4.9 KiB
TypeScript

import { useParams } from 'common'
import { parseAsString, useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Card,
cn,
Sheet,
SheetContent,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import { DeleteWrapperModal } from './DeleteWrapperModal'
import { EditWrapperSheet } from './EditWrapperSheet'
import { WrapperRow } from './WrapperRow'
import { wrapperMetaComparator } from './Wrappers.utils'
import { useFDWsQuery } from '@/data/fdw/fdws-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface WrapperTableProps {
isLatest?: boolean
}
export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => {
const { id, ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const integration = INTEGRATIONS.find((i) => i.id === id)
const [isClosingEditWrapper, setIsClosingEditWrapper] = useState(false)
const { data, isError, isSuccess } = useFDWsQuery({
projectRef: ref,
connectionString: project?.connectionString,
})
const wrappers = useMemo(
() =>
integration && integration.type === 'wrapper' && data
? data.filter((wrapper) => wrapperMetaComparator(integration.meta, wrapper))
: [],
[data, integration]
)
const [selectedWrapperIdToEdit, setSelectedWrapperToEdit] = useQueryState('edit', parseAsString)
const isSharedWrapper = (wrapper: (typeof wrappers)[number]) =>
data?.some((other) => other.id !== wrapper.id && other.name === wrapper.name) ?? false
const selectedWrapper = wrappers.find((w) => w.id.toString() === selectedWrapperIdToEdit)
const isSelectedWrapperShared = selectedWrapper !== undefined && isSharedWrapper(selectedWrapper)
const selectedWrapperToEdit = isSelectedWrapperShared ? undefined : selectedWrapper
const openedWrapperId = useRef<string | null>(null)
useEffect(() => {
if (!selectedWrapperIdToEdit) {
openedWrapperId.current = null
} else if (selectedWrapperToEdit) {
openedWrapperId.current = selectedWrapperIdToEdit
} else if (isSuccess || isError) {
if (openedWrapperId.current !== selectedWrapperIdToEdit) {
toast(
isSelectedWrapperShared
? 'Shared wrappers cannot be edited in the dashboard. Use the SQL Editor to edit this connection.'
: 'Wrapper not found'
)
}
setSelectedWrapperToEdit(null)
}
}, [
isError,
isSelectedWrapperShared,
isSuccess,
selectedWrapperIdToEdit,
selectedWrapperToEdit,
setSelectedWrapperToEdit,
])
if (!integration || integration.type !== 'wrapper') {
return (
<p className="text-foreground-light text-sm">
The referenced ID doesn't correspond to a wrapper integration
</p>
)
}
return (
<>
<Card className="max-w-5xl">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[220px]">Name</TableHead>
<TableHead>Tables</TableHead>
<TableHead>Encrypted key</TableHead>
<TableHead className="w-24">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(isLatest ? wrappers.slice(0, 3) : wrappers).map((x) => (
<WrapperRow key={x.id} wrapper={x} isShared={isSharedWrapper(x)} />
))}
</TableBody>
<TableFooter
className={cn(
'text-xs font-normal text-center text-foreground-muted',
// Prevent the footer from being highlighted on hover
'[&>tr>td]:hover:bg-inherit',
// Conditionally remove the border-top if there are no wrappers
wrappers.length === 0 ? 'border-t-0' : ''
)}
>
<TableRow className="border-b-0">
<TableCell colSpan={4}>
{wrappers.length} {integration?.name}
{wrappers.length === 0 || wrappers.length > 1 ? 's' : ''} created
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
<Sheet
open={!!selectedWrapperToEdit}
onOpenChange={(open) => {
if (!open) setIsClosingEditWrapper(true)
}}
>
<SheetContent size="lg">
{selectedWrapperToEdit && (
<EditWrapperSheet
wrapper={selectedWrapperToEdit}
wrapperMeta={integration.meta}
onClose={() => {
setSelectedWrapperToEdit(null)
setIsClosingEditWrapper(false)
}}
isClosing={isClosingEditWrapper}
setIsClosing={setIsClosingEditWrapper}
/>
)}
</SheetContent>
</Sheet>
<DeleteWrapperModal />
</>
)
}