From 5951fb6c4791cc58bf2dfdaa9a36fc3bb6ddec41 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Tue, 29 Sep 2026 11:42:58 +1000
Subject: [PATCH] fix(studio): polish Pipelines loading and update cues
(#50963)
## Problem
Pipelines loading causes layout shifts, and the update cue is hard to
connect to its menu action.
- Resolves
[PIPE-1078](https://linear.app/supabase/issue/PIPE-1078/show-destination-rows-while-details-are-loading)
- Resolves
[DEPR-688](https://linear.app/supabase/issue/DEPR-688/widen-the-update-available-modal)
- Resolves
[DEPR-691](https://linear.app/supabase/issue/DEPR-691/clarify-the-update-available-indicator-in-pipeline-actions)
## Solution
Reserve space for the graph and list while loading, show destination
rows before their details arrive, align the detail header, and stack
version values in the update dialog. Match the primary-colour dot on the
options button and its Update available menu item. Give the status
tooltip more room.
| Before | After |
| --- | --- |
| | |
| After |
| --- |
| |
| _Links ambiguous dot to dropdown menu item_ |
| |
| _Better alignment with nav bar above it_ |
## Review instructions
1. Reload Database > Pipelines and check the loading layout and
destination rows.
2. Open a pipeline detail page and check its header, update dialog, and
matching update dots.
## Checklist
- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
## Summary by CodeRabbit
* **Bug Fixes**
* Destination rows now appear while pipeline details are loading, with
controls becoming available when the details finish loading.
* **Style**
* Loading states on the replication page now use diagram and
table-shaped placeholders.
* Updated replication page spacing, version-status tooltips, update
indicators, and the version comparison layout.
---
.../Replication/DestinationRow.test.tsx | 43 +++++++-
.../Database/Replication/DestinationRow.tsx | 102 +++++++++---------
.../Database/Replication/Destinations.tsx | 6 +-
.../Replication/PipelineStatePill.tsx | 2 +-
.../Replication/ReplicationPipelineLayout.tsx | 2 +-
.../Database/Replication/RowMenu.tsx | 8 +-
.../Replication/UpdateVersionModal.tsx | 32 +++---
.../[ref]/database/pipelines/index.tsx | 55 +++++++---
8 files changed, 159 insertions(+), 91 deletions(-)
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx
index 24d0c73b802..78fab0b282e 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
+import { Table, TableBody } from 'ui'
import { describe, expect, test, vi } from 'vitest'
import { DestinationRow as DestinationRowComponent } from './DestinationRow'
@@ -68,12 +69,13 @@ const addDestinationMock = () =>
}),
})
-const addPipelinesMock = () =>
+const addPipelinesMock = (waitForResponse?: Promise) =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines',
- response: () =>
- HttpResponse.json({
+ response: async () => {
+ await waitForResponse
+ return HttpResponse.json({
pipelines: [
{
id: PIPELINE_ID,
@@ -89,7 +91,8 @@ const addPipelinesMock = () =>
},
},
],
- }),
+ })
+ },
})
const addPipelineStatusMock = (statusName: ReplicationPipelineStatusResponse['status']['name']) =>
@@ -145,6 +148,38 @@ describe('DestinationRow', () => {
addVersionMock()
}
+ test('shows the destination before its pipeline details load', async () => {
+ let finishPipelineRequest = () => {}
+ const pendingPipeline = new Promise((resolve) => {
+ finishPipelineRequest = resolve
+ })
+ addSourcesMock()
+ addDestinationMock()
+ addPipelinesMock(pendingPipeline)
+ addPipelineStatusMock('started')
+ addReplicationStatusMock(0)
+ addVersionMock()
+
+ customRender(
+
+ )
+
+ const row = (await screen.findByText('My BigQuery Destination')).closest('tr')
+ expect(row).toBeInTheDocument()
+ expect(row).not.toHaveAttribute('tabindex')
+ expect(screen.queryByRole('button', { name: 'Pipeline options' })).not.toBeInTheDocument()
+
+ await act(async () => finishPipelineRequest())
+
+ expect(await screen.findByText('supabase_realtime')).toBeInTheDocument()
+ expect(row).toHaveAttribute('tabindex', '0')
+ expect(screen.getByRole('button', { name: 'Pipeline options' })).toBeInTheDocument()
+ })
+
test('waits for asynchronous shutdown before deleting the pipeline', async () => {
addAllMocks()
routerMock.setCurrentUrl('/project/default/database/pipelines')
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
index 7766862c6da..d9c70607358 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
@@ -3,7 +3,7 @@ import { ChevronRight, Minus } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
-import { TableCell, TableRow } from 'ui'
+import { cn, TableCell, TableRow } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { DeleteDestination } from './DeleteDestination'
@@ -50,9 +50,8 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
error: pipelineError,
isPending: isPipelineLoading,
isError: isPipelineError,
- isSuccess: isPipelineSuccess,
} = pipelineFetcher
- const destinationName = destination?.name ?? ''
+ const destinationName = destination?.name ?? pipeline?.destination_name ?? ''
const {
error: pipelineStatusError,
@@ -134,8 +133,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
}
}
- // Five distinct states, so early returns rather than a ternary chain. The row only renders once
- // a pipeline exists, so there is no "no pipeline" case to handle here.
+ // Five distinct states, so early returns rather than a ternary chain.
const renderLag = () => {
if (isReplicationStatusLoading) return
if (isInitialSyncRunning)
@@ -161,39 +159,41 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
)}
- {isPipelineSuccess && pipeline && (
+ {(isPipelineLoading || pipeline) && (
{type ? : null}
- {isPipelineLoading ? (
+ {isPipelineLoading && !destinationName ? (
) : (
{destinationName || type}
-
- #{pipeline?.id}
- ·
- {type}
- {hasTableErrors && (
- <>
- ·
-
- {errorCount} table error{errorCount === 1 ? '' : 's'}
-
- >
- )}
-
+ {pipeline && (
+
+ #{pipeline.id}
+ ·
+ {type}
+ {hasTableErrors && (
+ <>
+ ·
+
+ {errorCount} table error{errorCount === 1 ? '' : 's'}
+
+ >
+ )}
+
+ )}
)}
@@ -232,32 +232,38 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
-
event.stopPropagation()}
- onAuxClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => event.stopPropagation()}
- >
- setShowDeleteDestinationForm(true)}
- hasUpdate={hasUpdate}
- onUpdateClick={() => setShowUpdateVersionModal(true)}
- />
-
-
-
- Go to pipeline details
-
+ {pipeline ? (
+ <>
+
event.stopPropagation()}
+ onAuxClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => event.stopPropagation()}
+ >
+ setShowDeleteDestinationForm(true)}
+ hasUpdate={hasUpdate}
+ onUpdateClick={() => setShowUpdateVersionModal(true)}
+ />
+
+
+
+ Go to pipeline details
+
+ >
+ ) : (
+
+ )}
diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx
index a65f24d008b..2e21f98ddc3 100644
--- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx
@@ -22,7 +22,7 @@ import {
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
-import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader'
import { DestinationPanel } from './DestinationPanel/DestinationPanel'
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
@@ -363,7 +363,9 @@ export const Destinations = () => {
)}
- {isDestinationsLoading && }
+ {isDestinationsLoading && (
+
+ )}
{!isDestinationsLoading && hasDestinations && (
diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx
index 86f5de20ae2..b5245b3ea66 100644
--- a/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx
@@ -81,7 +81,7 @@ export const PipelineStatePill = ({
{shouldShowError ? 'Unknown' : label}
-
+
{tooltipMessage}
{isError && isRequestPending && ` Unable to refresh status: ${error?.message}.`}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx
index efc35800f6c..eea85b96784 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx
@@ -244,7 +244,7 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => {
-
+
{isPipelineIdentityLoading ? (
diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
index 1246f26ad9f..2629cb1e7fc 100644
--- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
@@ -166,7 +166,7 @@ export const RowMenu = ({
/>
{hasUpdate && (
)}
@@ -184,12 +184,16 @@ export const RowMenu = ({
{hasUpdate && (
<>
onUpdateClick?.()}
disabled={isPipelineRequestPending}
>
Update available
+
>
diff --git a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx
index 2aa7531d2e8..5629d7239fc 100644
--- a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx
@@ -83,24 +83,20 @@ export const UpdateVersionModal = ({ visible, pipeline, onClose }: UpdateVersion
? 'A newer pipeline version is available with improvements and bug fixes. The pipeline will restart and continue from where it left off.'
: 'A newer pipeline version is available with improvements and bug fixes.'}
-
-
-
-
- Current
-
- {isLoadingVersion ? 'Loading…' : (currentVersionName ?? 'Unknown')}
-
-
-
- New
-
- {isLoadingVersion ? 'Loading…' : (newVersionName ?? 'Unknown')}
-
-
-
-
-
+
+
+
Current
+
+ {isLoadingVersion ? 'Loading…' : (currentVersionName ?? 'Unknown')}
+
+
+
+
New
+
+ {isLoadingVersion ? 'Loading…' : (newVersionName ?? 'Unknown')}
+
+
+
)
diff --git a/apps/studio/pages/project/[ref]/database/pipelines/index.tsx b/apps/studio/pages/project/[ref]/database/pipelines/index.tsx
index 6c030934ed4..fa6223730fb 100644
--- a/apps/studio/pages/project/[ref]/database/pipelines/index.tsx
+++ b/apps/studio/pages/project/[ref]/database/pipelines/index.tsx
@@ -1,3 +1,4 @@
+import { useParams } from 'common'
import { MessageSquare } from 'lucide-react'
import { Button } from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
@@ -10,7 +11,7 @@ import {
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
-import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+import { GenericTableLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { Destinations } from '@/components/interfaces/Database/Replication/Destinations'
import { PIPELINES_FEEDBACK_URL } from '@/components/interfaces/Database/Replication/Replication.constants'
@@ -22,17 +23,16 @@ import { DocsButton } from '@/components/ui/DocsButton'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
-import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
import type { NextPageWithLayout } from '@/types'
const DatabasePipelinesPage: NextPageWithLayout = () => {
- const { data: selectedProject, isPending } = useSelectedProjectQuery()
- const { isHighAvailability } = useHighAvailability()
+ const { ref: projectRef } = useParams()
+ const { isHighAvailability, isPending } = useHighAvailability()
const showPgReplicate = useIsFeatureEnabled('database:replication')
if (!showPgReplicate) {
- return
+ return
}
if (isHighAvailability) {
@@ -81,16 +81,41 @@ const DatabasePipelinesPage: NextPageWithLayout = () => {
- {isPending ? (
-
- ) : (
-
-
-
-
-
-
- )}
+
+
+ {isPending ? (
+ <>
+
+ Loading pipelines
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
>
)