mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
## Problem AlertError forces a vertical layout whenever additional actions are supplied, even when the caller explicitly requests horizontal or responsive layout. Stripe Sync Engine's uninstall error is one affected call site, the other was Pipelines as demoed in #51311. ## Solution Honour an explicit layout. Preserve the existing defaults: vertical with additional actions, responsive otherwise. Replace the standalone Contact support action with an InlineLink in the contact support prose, preserving support form context and breadcrumb capture. Keep custom actions such as Retry. If instructions are hidden or custom prose omits contact support, retain a separate inline support link. With `hideContactSupport`, show no support link and shorten the default instructions to “Try refreshing your browser.” Custom descriptions remain unchanged. The local Pipelines configuration error explicitly hides support. Add the explicit responsive layout at the Pipelines call site. | Before | After | | --- | --- | | <img width="914" height="426" alt="CleanShot 2026-10-07 at 17 51 20@2x" src="https://github.com/user-attachments/assets/6cbddfee-97f5-4a7a-bafa-7b5dfd6ab8bf" /> | <img width="916" height="422" alt="CleanShot 2026-10-07 at 18 26 06@2x" src="https://github.com/user-attachments/assets/a21952a6-1c1a-437c-af73-c5736f33fd98" /> | | <img width="1566" height="384" alt="CleanShot 2026-10-07 at 18 28 07@2x" src="https://github.com/user-attachments/assets/bdc2a043-421f-4c22-9bd3-37859c6e85c7" /> | <img width="1568" height="308" alt="CleanShot 2026-10-07 at 18 26 58@2x" src="https://github.com/user-attachments/assets/da0f2c6a-6254-4196-944b-5665e87b3c3c" /> | ## Review instructions 1. In a fresh local test project with no existing `stripe` schema, run this in SQL Editor: ```sql begin; create schema stripe; comment on schema stripe is '{"status":"uninstall error","errorMessage":"Local layout test: uninstallation failed"}'; commit; ``` 2. Open **Integrations → Stripe Sync Engine → Overview** and reload. Check **Failed to uninstall Stripe Sync Engine** at wide and narrow widths, including **Retry uninstallation** and the inline **contact support** link. Do not click Retry: it invokes the real uninstall operation. 3. Remove the empty fixture with `drop schema stripe restrict;`. 4. Block the Pipelines source-status request and resize the page: Retry uses the responsive layout. 5. AlertError callers without an explicit layout should retain their existing presentation. Confirm default and custom contact support prose use an inline link, with no standalone support action. Hidden instructions and custom prose without contact support retain an inline fallback. With `hideContactSupport`, the default prose is “Try refreshing your browser.” and no support link appears; custom descriptions remain unchanged. 6. Automated regression coverage checks that an explicit responsive layout survives additional actions, and that hiding support removes the default support wording while preserving custom descriptions. --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
import { useQueries, useQueryClient } from '@tanstack/react-query'
|
|
import { useParams } from 'common'
|
|
import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { parseAsStringEnum, useQueryState } from 'nuqs'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
Table,
|
|
TableBody,
|
|
TableHead,
|
|
TableHeader,
|
|
TableHeadSort,
|
|
TableRow,
|
|
} from 'ui'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
|
import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { DestinationPanel } from './DestinationPanel/DestinationPanel'
|
|
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
|
|
import { DestinationRow } from './DestinationRow'
|
|
import { DisablePipelinesDialog } from './DisablePipelinesDialog'
|
|
import { EnablePipelinesModal } from './EnablePipelinesCallout'
|
|
import { getStatusName } from './Pipeline.utils'
|
|
import { PipelineStatusName } from './Replication.constants'
|
|
import { useRedirectLegacyReadReplicaDestination } from './useRedirectLegacyReadReplicaDestination'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { Shortcut } from '@/components/ui/Shortcut'
|
|
import { TableRowNoResults } from '@/components/ui/TableRowNoResults'
|
|
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
|
|
import { replicationKeys } from '@/data/replication/keys'
|
|
import {
|
|
replicationPipelineStatusQueryOptions,
|
|
type ReplicationPipelineStatusData,
|
|
} from '@/data/replication/pipeline-status-query'
|
|
import { fetchReplicationPipelineVersion } from '@/data/replication/pipeline-version-query'
|
|
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
|
|
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
|
|
import { checkLocalETLNotSetUp } from '@/data/replication/utils'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { onSearchInputEscape } from '@/lib/keyboard'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
|
|
type DestinationSortColumn = 'name' | 'status'
|
|
type DestinationSort = `${DestinationSortColumn}:${'asc' | 'desc'}`
|
|
|
|
// Worst first, so sorting ascending by status surfaces the pipelines that need attention.
|
|
const STATUS_SORT_ORDER: PipelineStatusName[] = [
|
|
PipelineStatusName.FAILED,
|
|
PipelineStatusName.STOPPED,
|
|
PipelineStatusName.STOPPING,
|
|
PipelineStatusName.STARTING,
|
|
PipelineStatusName.STARTED,
|
|
PipelineStatusName.UNKNOWN,
|
|
]
|
|
|
|
// Keyed by pipeline id from the responses themselves, so this never closes over component state.
|
|
const combinePipelineStatuses = (
|
|
results: { data?: ReplicationPipelineStatusData }[]
|
|
): Map<number, PipelineStatusName | undefined> =>
|
|
new Map(
|
|
results
|
|
.map((result) => result.data)
|
|
.filter((data): data is ReplicationPipelineStatusData => data !== undefined)
|
|
.map((data) => [data.pipeline_id, getStatusName(data.status)])
|
|
)
|
|
|
|
const compareStatusNames = (
|
|
a: PipelineStatusName | undefined,
|
|
b: PipelineStatusName | undefined,
|
|
direction: 'asc' | 'desc'
|
|
) => {
|
|
if (a === undefined) return b === undefined ? 0 : 1
|
|
if (b === undefined) return -1
|
|
|
|
const comparison = STATUS_SORT_ORDER.indexOf(a) - STATUS_SORT_ORDER.indexOf(b)
|
|
return direction === 'asc' ? comparison : -comparison
|
|
}
|
|
|
|
export const Destinations = () => {
|
|
const queryClient = useQueryClient()
|
|
const { ref: projectRef } = useParams()
|
|
const { data: organization } = useSelectedOrganizationQuery()
|
|
|
|
useRedirectLegacyReadReplicaDestination()
|
|
|
|
const prefetchedRef = useRef(false)
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
const [filterString, setFilterString] = useState<string>('')
|
|
const [showEnablePipelinesDialog, setShowEnablePipelinesDialog] = useState(false)
|
|
const pendingCreationTypeRef = useRef<DestinationType | null>(null)
|
|
const [showDisablePipelinesDialog, setShowDisablePipelinesDialog] = useState(false)
|
|
|
|
const [, setDestinationType] = useQueryState(
|
|
'destinationType',
|
|
parseAsStringEnum<DestinationType>([
|
|
'BigQuery',
|
|
'Analytics Bucket',
|
|
'DuckLake',
|
|
'Snowflake',
|
|
'ClickHouse',
|
|
]).withOptions({
|
|
history: 'push',
|
|
clearOnDefault: true,
|
|
})
|
|
)
|
|
|
|
const {
|
|
data: destinationsData,
|
|
error: destinationsError,
|
|
isPending: isDestinationsLoading,
|
|
isError: isDestinationsError,
|
|
isSuccess: isDestinationsSuccess,
|
|
} = useReplicationDestinationsQuery({
|
|
projectRef,
|
|
})
|
|
const destinations = useMemo(
|
|
() => destinationsData?.destinations ?? [],
|
|
[destinationsData?.destinations]
|
|
)
|
|
const hasDestinations = isDestinationsSuccess && destinationsData?.destinations.length > 0
|
|
const filteredDestinations = useMemo(
|
|
() =>
|
|
filterString.length === 0
|
|
? destinations
|
|
: destinations.filter((destination) =>
|
|
destination.name.toLowerCase().includes(filterString.toLowerCase())
|
|
),
|
|
[destinations, filterString]
|
|
)
|
|
|
|
const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({
|
|
projectRef,
|
|
})
|
|
const pipelines = useMemo(() => pipelinesData?.pipelines ?? [], [pipelinesData?.pipelines])
|
|
|
|
// Sorting by status needs every pipeline's status up here, not just inside each row. These share
|
|
// the rows' query keys, so each status is still only fetched once.
|
|
const statusByPipelineId = useQueries({
|
|
queries: pipelines.map((pipeline) =>
|
|
replicationPipelineStatusQueryOptions({ projectRef, pipelineId: pipeline.id })
|
|
),
|
|
combine: combinePipelineStatuses,
|
|
})
|
|
|
|
const getDestinationStatus = (destinationId: number) => {
|
|
const pipeline = pipelines.find((p) => p.destination_id === destinationId)
|
|
return pipeline === undefined ? undefined : statusByPipelineId.get(pipeline.id)
|
|
}
|
|
|
|
const [sort, setSort] = useState<DestinationSort>('name:asc')
|
|
const [sortColumn, sortDirection] = sort.split(':') as [DestinationSortColumn, 'asc' | 'desc']
|
|
|
|
const getAriaSort = (column: DestinationSortColumn) => {
|
|
if (sortColumn !== column) return 'none'
|
|
return sortDirection === 'asc' ? 'ascending' : 'descending'
|
|
}
|
|
|
|
const handleSortChange = (column: DestinationSortColumn) => {
|
|
if (sortColumn !== column) return setSort(`${column}:asc`)
|
|
setSort(`${column}:${sortDirection === 'asc' ? 'desc' : 'asc'}`)
|
|
}
|
|
|
|
// Not memoized: the status map is rebuilt whenever a pipeline status refetches, so a useMemo
|
|
// here would never hit. Sorting a handful of destinations per render costs nothing.
|
|
const sortedDestinations = [...filteredDestinations].sort((a, b) => {
|
|
if (sortColumn === 'status') {
|
|
const nameComparison = a.name.localeCompare(b.name)
|
|
|
|
return (
|
|
compareStatusNames(getDestinationStatus(a.id), getDestinationStatus(b.id), sortDirection) ||
|
|
(sortDirection === 'asc' ? nameComparison : -nameComparison)
|
|
)
|
|
}
|
|
|
|
const comparison = a.name.localeCompare(b.name)
|
|
return sortDirection === 'asc' ? comparison : -comparison
|
|
})
|
|
|
|
const {
|
|
data: sourcesData,
|
|
isSuccess: isSourcesSuccess,
|
|
isError: isSourcesError,
|
|
error: sourcesError,
|
|
refetch: refetchSources,
|
|
} = useReplicationSourcesQuery({
|
|
projectRef,
|
|
})
|
|
const externalReplicationSource = useMemo(
|
|
() => sourcesData?.sources.find((source) => source.name === projectRef),
|
|
[projectRef, sourcesData?.sources]
|
|
)
|
|
const replicationNotEnabled = isSourcesSuccess && !externalReplicationSource
|
|
|
|
const canDisablePipelines =
|
|
isSourcesSuccess &&
|
|
isDestinationsSuccess &&
|
|
isPipelinesSuccess &&
|
|
!!externalReplicationSource &&
|
|
destinations.length === 0 &&
|
|
pipelines.length === 0
|
|
|
|
const isLocalETLNotSetUp = checkLocalETLNotSetUp(destinationsError)
|
|
const hasErrorsFetchingData = !isLocalETLNotSetUp && isDestinationsError
|
|
|
|
const canCreate = isSourcesSuccess
|
|
const isCheckingPipelineStatus = !isSourcesSuccess && !isSourcesError
|
|
const sourceErrorTitle = checkLocalETLNotSetUp(sourcesError)
|
|
? 'Replication unavailable locally'
|
|
: 'Failed to retrieve pipeline enablement status'
|
|
|
|
const openDestinationPanel = () => {
|
|
if (!canCreate) return
|
|
if (replicationNotEnabled) {
|
|
pendingCreationTypeRef.current = 'BigQuery'
|
|
setShowEnablePipelinesDialog(true)
|
|
return
|
|
}
|
|
setDestinationType('BigQuery')
|
|
}
|
|
|
|
const handleEnableDialogOpenChange = (open: boolean) => {
|
|
setShowEnablePipelinesDialog(open)
|
|
if (!open) pendingCreationTypeRef.current = null
|
|
}
|
|
|
|
const handlePipelinesEnabled = () => {
|
|
const type = pendingCreationTypeRef.current
|
|
pendingCreationTypeRef.current = null
|
|
if (
|
|
type &&
|
|
queryClient.getQueryState(replicationKeys.sources(projectRef))?.status === 'success'
|
|
) {
|
|
setDestinationType(type)
|
|
}
|
|
}
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
|
() => {
|
|
searchInputRef.current?.focus()
|
|
searchInputRef.current?.select()
|
|
},
|
|
{ label: 'Search pipelines' }
|
|
)
|
|
|
|
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setFilterString(''))
|
|
|
|
useEffect(() => {
|
|
if (
|
|
projectRef &&
|
|
!prefetchedRef.current &&
|
|
pipelinesData?.pipelines &&
|
|
pipelinesData.pipelines.length > 0 &&
|
|
isPipelinesSuccess
|
|
) {
|
|
prefetchedRef.current = true
|
|
pipelinesData.pipelines.forEach((p) => {
|
|
if (!p?.id) return
|
|
queryClient.prefetchQuery({
|
|
queryKey: replicationKeys.pipelinesVersion(projectRef, p.id),
|
|
queryFn: ({ signal }) =>
|
|
fetchReplicationPipelineVersion({ projectRef, pipelineId: p.id }, signal),
|
|
staleTime: Infinity,
|
|
})
|
|
})
|
|
}
|
|
}, [projectRef, pipelinesData?.pipelines, isPipelinesSuccess, queryClient])
|
|
|
|
return (
|
|
<div className="w-full space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center">
|
|
<Input
|
|
ref={searchInputRef}
|
|
placeholder="Search pipelines"
|
|
size="tiny"
|
|
icon={<Search />}
|
|
value={filterString}
|
|
className="w-full lg:w-52"
|
|
onChange={(e) => setFilterString(e.target.value)}
|
|
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
|
actions={
|
|
filterString.length > 0 && (
|
|
<Button
|
|
aria-label="Clear search"
|
|
variant="text"
|
|
icon={<X />}
|
|
className="p-0 h-5 w-5"
|
|
onClick={() => setFilterString('')}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-x-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
aria-label="More actions"
|
|
variant="default"
|
|
icon={<MoreVertical />}
|
|
className="w-6.5 hit-area-1"
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64">
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/org/${organization?.slug}/usage#pipeline-initial-sync-data`}>
|
|
View Pipelines usage
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
{replicationNotEnabled ? (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
pendingCreationTypeRef.current = null
|
|
setShowEnablePipelinesDialog(true)
|
|
}}
|
|
>
|
|
Enable Pipelines
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem
|
|
className="data-disabled:pointer-events-auto data-disabled:cursor-not-allowed"
|
|
disabled={!canDisablePipelines}
|
|
onClick={() => {
|
|
if (!canDisablePipelines) return
|
|
setShowDisablePipelinesDialog(true)
|
|
}}
|
|
>
|
|
<div className="flex flex-col gap-y-0.5">
|
|
<p>Disable Pipelines</p>
|
|
{!canDisablePipelines && (
|
|
<p className="text-foreground-lighter">Delete all pipelines first</p>
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Shortcut
|
|
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
|
label="Add pipeline"
|
|
onTrigger={openDestinationPanel}
|
|
options={{ enabled: canCreate }}
|
|
side="bottom"
|
|
tooltipOpen={canCreate ? undefined : false}
|
|
>
|
|
<ButtonTooltip
|
|
tooltip={{
|
|
content: { text: isSourcesError && !canCreate ? sourceErrorTitle : undefined },
|
|
}}
|
|
variant="primary"
|
|
icon={<Plus />}
|
|
loading={isCheckingPipelineStatus}
|
|
disabled={!canCreate}
|
|
onClick={openDestinationPanel}
|
|
>
|
|
Add pipeline
|
|
</ButtonTooltip>
|
|
</Shortcut>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full overflow-hidden overflow-x-auto flex flex-col gap-y-4">
|
|
{/* Mounted whether or not it has anything to say, so the update is announced */}
|
|
<p role="status" aria-live="polite" className="sr-only">
|
|
{isDestinationsLoading ? 'Loading pipelines' : ''}
|
|
</p>
|
|
|
|
{isSourcesError && (
|
|
<AlertError
|
|
layout="responsive"
|
|
projectRef={projectRef}
|
|
error={sourcesError}
|
|
subject={sourceErrorTitle}
|
|
hideContactSupport={checkLocalETLNotSetUp(sourcesError)}
|
|
description={
|
|
checkLocalETLNotSetUp(sourcesError)
|
|
? 'Configure the replication API to manage pipelines in local development.'
|
|
: undefined
|
|
}
|
|
additionalActions={<Button onClick={() => refetchSources()}>Retry</Button>}
|
|
/>
|
|
)}
|
|
|
|
{hasErrorsFetchingData && (
|
|
<AlertError error={destinationsError} subject="Failed to retrieve pipelines" />
|
|
)}
|
|
|
|
{isDestinationsLoading && (
|
|
<GenericTableLoader headers={[null, 'Name', 'Status', 'Lag', 'Publication', null]} />
|
|
)}
|
|
|
|
{!isDestinationsLoading && hasDestinations && (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead key="type" className="w-[40px]" />
|
|
<TableHead key="name" className="w-[250px]" aria-sort={getAriaSort('name')}>
|
|
<TableHeadSort
|
|
column="name"
|
|
currentSort={sort}
|
|
onSortChange={handleSortChange}
|
|
>
|
|
Name
|
|
</TableHeadSort>
|
|
</TableHead>
|
|
<TableHead key="status" className="w-[150px]" aria-sort={getAriaSort('status')}>
|
|
<TableHeadSort
|
|
column="status"
|
|
currentSort={sort}
|
|
onSortChange={handleSortChange}
|
|
>
|
|
Status
|
|
</TableHeadSort>
|
|
</TableHead>
|
|
<TableHead key="lag" className="w-[150px]">
|
|
Lag
|
|
</TableHead>
|
|
<TableHead key="publication">Publication</TableHead>
|
|
<TableHead key="actions" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sortedDestinations.map((destination) => (
|
|
<DestinationRow key={destination.id} destinationId={destination.id} />
|
|
))}
|
|
|
|
{!isDestinationsLoading &&
|
|
filteredDestinations.length === 0 &&
|
|
hasDestinations && <TableRowNoResults colSpan={6} search={filterString} />}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!isDestinationsLoading && !hasDestinations && !hasErrorsFetchingData && (
|
|
<EmptyStatePresentational
|
|
icon={Workflow}
|
|
title="Add a pipeline"
|
|
description="Send tables to an external destination for analytics workloads."
|
|
>
|
|
<ButtonTooltip
|
|
tooltip={{
|
|
content: { text: isSourcesError && !canCreate ? sourceErrorTitle : undefined },
|
|
}}
|
|
variant="default"
|
|
icon={<Plus />}
|
|
loading={isCheckingPipelineStatus}
|
|
disabled={!canCreate}
|
|
onClick={openDestinationPanel}
|
|
>
|
|
Add pipeline
|
|
</ButtonTooltip>
|
|
</EmptyStatePresentational>
|
|
)}
|
|
</div>
|
|
|
|
<DestinationPanel />
|
|
|
|
<EnablePipelinesModal
|
|
open={showEnablePipelinesDialog}
|
|
onOpenChange={handleEnableDialogOpenChange}
|
|
onSuccess={handlePipelinesEnabled}
|
|
/>
|
|
|
|
<DisablePipelinesDialog
|
|
open={showDisablePipelinesDialog}
|
|
setOpen={setShowDisablePipelinesDialog}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|