refactor(studio): simplify pipeline reset dialogs (#50443)

## What kind of change does this PR introduce?

Studio UI refactor.

## What is the current behavior?

Pipeline table reset dialogs repeat explanatory content and use more
layout than the reset decision needs.

## What is the new behavior?

Simplifies the single-table and batch reset confirmations while
preserving their cost estimate, destructive consequences, and existing
reset mutations.

| Before | After |
| --- | --- |
| <img width="854" height="1090" alt="CleanShot 2026-09-16 at 12 54
36@2x"
src="https://github.com/user-attachments/assets/f9eef09b-89d1-4747-bc4c-e81fb64c584b"
/> | <img width="840" height="742" alt="CleanShot 2026-09-16 at 17 01
11@2x"
src="https://github.com/user-attachments/assets/fa45728c-ec66-45c8-9fef-9d2eb8310d4d"
/> |

This is an independent slice extracted from #49630. The related review
series is this one, #50444, #50445, #50446, then #49630.

## To test

1. Open `/project/<ref>/database/replication` and select a pipeline.
2. Reset one replicated table and confirm the dialog explains that
destination data will be deleted and resynchronised.
3. Choose **Reset all tables** and confirm the batch dialog shows the
same concise treatment.

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

## UI Updates

* **UI Updates**
* Renamed replication “restart” actions to “reset” across dialogs,
buttons, notifications, and cost estimates.
* Updated messaging to clarify whether the pipeline will start or
restart automatically after resetting.
* Added clearer initial-sync guidance for all, some, or none of the
affected tables.
* Improved reset cost estimate messaging, including when no additional
initial-sync charge applies.
* Updated reset dialogs with clearer titles, descriptions, loading
states, and error messages.
  * Disabled reset actions when pipeline status is unavailable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-09-17 12:13:51 +10:00
1 parent cc540ff302
commit 0b002892d7
4 files changed
+67 -127

No files matched your search

@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { BatchRestartDialog } from './BatchRestartDialog'
import { PipelineStatusName } from './Replication.constants'
import type { ReplicationPipelineTableStatus } from '@/data/replication/pipeline-replication-status-query'
const mocks = vi.hoisted(() => ({
@@ -58,15 +59,21 @@ describe('BatchRestartDialog', () => {
mode="errored"
tables={tables}
tableSyncCopy={{ type: 'include_tables', table_ids: [1, 2] }}
pipelineStatusName={PipelineStatusName.STARTED}
onRestartStart={onRestartStart}
/>
)
expect(screen.getByText(/3 currently failed tables/)).toBeInTheDocument()
expect(screen.getByText(/This resets 3 failed tables/)).toBeInTheDocument()
expect(
screen.getByText(
/2 of 3 tables will sync existing rows again. The remaining 1 table will skip initial sync/
)
).toBeInTheDocument()
expect(screen.getByTestId('copy-targets')).toHaveTextContent('public.table_1,public.table_2')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Restart failed tables' }))
fireEvent.click(screen.getByRole('button', { name: 'Reset failed tables' }))
})
expect(onRestartStart).toHaveBeenCalledWith([1, 2, 3])
@@ -78,4 +85,17 @@ describe('BatchRestartDialog', () => {
})
)
})
it('prevents a reset when the pipeline status is unavailable', () => {
render(
<BatchRestartDialog
open
onOpenChange={vi.fn()}
mode="all"
tables={[table(1, { name: 'following_wal' })]}
/>
)
expect(screen.getByRole('button', { name: 'Reset all tables' })).toBeDisabled()
})
})
@@ -54,43 +54,19 @@ export const BatchRestartDialog = ({
}
}, [mode, tables])
const affectedTableIds = useMemo(() => affectedTables.map((table) => table.id), [affectedTables])
const isPipelineStatusUnavailable = pipelineStatusName === undefined
const copiedTables = useMemo(
() => getTableCopyTargets(affectedTables, tableSyncCopy),
[affectedTables, tableSyncCopy]
)
const initialSyncDescription =
copiedTables.length === 0 ? (
<li>
<strong>No table will run an initial sync.</strong> Replication will resume with new changes
only, without syncing existing source rows. There is no additional initial sync charge.
</li>
) : copiedTables.length === affectedTables.length ? (
<li>
<strong>
{copiedTables.length === 1
? 'The table will run its initial sync again.'
: `All ${copiedTables.length} tables will run initial sync again.`}
</strong>{' '}
Existing source rows will be synced again. Data successfully processed during this initial
sync is billed again.
</li>
) : (
<li>
<strong>
{copiedTables.length} of {affectedTables.length} tables will run initial sync again.
</strong>{' '}
Existing source rows for those tables will be synced again and billed again. The remaining
tables will resume replication with new changes only.
</li>
)
const pipelineAction = pipelineStatusName === PipelineStatusName.STOPPED ? 'start' : 'restart'
const { mutateAsync: rollbackTables, isPending: isResetting } = useRollbackTablesMutation({
onSuccess: (data) => {
const count = data.tables.length
toast.success(
`Restarting replication for ${count} table${count > 1 ? 's' : ''}. Pipeline will restart automatically.`
`Resetting ${count} table${count > 1 ? 's' : ''}. Pipeline will ${pipelineAction} automatically.`
)
},
onSettled: () => {
@@ -98,12 +74,13 @@ export const BatchRestartDialog = ({
onOpenChange(false)
},
onError: (error) => {
toast.error(`Failed to restart replication: ${error.message}`)
toast.error(`Failed to reset tables: ${error.message}`)
},
})
const handleReset = async () => {
if (!projectRef) return toast.error('Project ref is required')
if (isPipelineStatusUnavailable) return
onRestartStart?.(affectedTableIds)
@@ -118,57 +95,28 @@ export const BatchRestartDialog = ({
} catch (error) {}
}
const count = affectedTables.length
const tableWord = count === 1 ? 'table' : 'tables'
const remainingTableCount = count - copiedTables.length
const remainingTableWord = remainingTableCount === 1 ? 'table' : 'tables'
const initialSyncDescription =
copiedTables.length === 0
? 'Initial sync is skipped, so replication resumes with new changes only.'
: copiedTables.length === affectedTables.length
? 'Existing rows will sync again.'
: `${copiedTables.length} of ${count} ${tableWord} will sync existing rows again. The remaining ${remainingTableCount} ${remainingTableWord} will skip initial sync and resume with new changes only.`
const dialogContent =
mode === 'all'
? {
title: 'Restart all tables',
description: (
<div className="space-y-3 text-sm">
<p>
This will restart replication for all {affectedTables.length} table
{affectedTables.length === 1 ? '' : 's'} in this pipeline from scratch:
</p>
<ul className="list-disc list-inside space-y-1.5 pl-2">
{initialSyncDescription}
<li>
<strong>All downstream data will be deleted.</strong> All replicated data will be
removed.
</li>
<li>
<strong>The pipeline will restart automatically.</strong> This is required to
apply this change.
</li>
</ul>
</div>
),
action: 'Restart all tables',
title: 'Reset all tables',
description: `This resets all ${count} ${tableWord}. Destination data will be deleted. ${initialSyncDescription} The pipeline will ${pipelineAction} automatically.`,
action: 'Reset all tables',
}
: {
title: 'Restart failed tables',
description: (
<div className="space-y-3 text-sm">
<p>
This will restart replication for all{' '}
<strong>{affectedTables.length} currently failed tables</strong> from scratch:
</p>
<ul className="list-disc list-inside space-y-1.5 pl-2">
{initialSyncDescription}
<li>
<strong>Existing downstream data will be deleted.</strong> Replicated data for
these tables will be removed.
</li>
<li>
<strong>Tables that are not failed remain untouched.</strong> The request resets
every table that is failed when it runs.
</li>
<li>
<strong>The pipeline will restart automatically.</strong> This is required to
apply this change.
</li>
</ul>
</div>
),
action: 'Restart failed tables',
title: 'Reset failed tables',
description: `This resets ${count} failed ${tableWord}. Destination data for those tables will be deleted. ${initialSyncDescription} The pipeline will ${pipelineAction} automatically. Other tables stay as they are.`,
action: 'Reset failed tables',
}
return (
@@ -176,7 +124,7 @@ export const BatchRestartDialog = ({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{dialogContent.title}</AlertDialogTitle>
<AlertDialogDescription asChild>{dialogContent.description}</AlertDialogDescription>
<AlertDialogDescription>{dialogContent.description}</AlertDialogDescription>
</AlertDialogHeader>
<RestartCostEstimate
open={open}
@@ -187,8 +135,12 @@ export const BatchRestartDialog = ({
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResetting}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={isResetting} onClick={handleReset} variant="warning">
{isResetting ? 'Restarting replication...' : dialogContent.action}
<AlertDialogAction
disabled={isResetting || isPipelineStatusUnavailable}
onClick={handleReset}
variant="warning"
>
{isResetting ? 'Resetting…' : dialogContent.action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -41,13 +41,13 @@ export const RestartCostEstimate = ({
[estimate, tables]
)
return (
<div className="border-t p-4">
<div className="space-y-2 border-t px-5 py-4">
{tables.length === 0 ? (
<div className="flex items-center justify-between gap-x-6">
<div className="min-w-0">
<p className="text-sm font-medium">No additional initial sync charge</p>
<p className="text-xs text-foreground-lighter">
This restart will skip initial sync based on the pipeline's settings.
This reset will skip initial sync based on the pipeline’s settings.
</p>
</div>
<span className="shrink-0 font-mono text-lg font-semibold" translate="no">
@@ -71,18 +71,18 @@ export const RestartCostEstimate = ({
</span>
</div>
) : (
<p className="text-xs text-foreground-lighter">
A cost estimate is unavailable. You can still restart the{' '}
<p className="text-sm text-foreground-light">
A cost estimate is unavailable. You can still reset the{' '}
{tables.length === 1 ? 'table' : 'tables'}.
</p>
)}
{restartEstimate?.isComplete && restartEstimate.hasRowFilteredTables && (
<p className="mt-2 text-xs text-foreground-lighter">
<p className="text-xs text-foreground-lighter">
*Row filters can reduce the data processed compared with this estimate.
</p>
)}
{restartEstimate?.isComplete && (
<p className="mt-2 text-xs text-foreground-lighter">
<p className="text-xs text-foreground-lighter">
Quick planning estimate; the final charge is based on successfully processed initial sync
data, which is billed again.
</p>
@@ -44,19 +44,18 @@ export const RestartTableDialog = ({
const pipelineId = Number(_pipelineId)
const tableName = `${table.schema}.${table.name}`
const willCopyTable = shouldCopyTable(tableSyncCopy, table.id)
const pipelineAction = pipelineStatusName === PipelineStatusName.STOPPED ? 'start' : 'restart'
const { mutate: rollbackTables, isPending: isResetting } = useRollbackTablesMutation({
onSuccess: () => {
toast.success(
`Restarting replication for "${tableName}". Pipeline will ${pipelineStatusName === PipelineStatusName.STOPPED ? 'start' : 'restart'} automatically.`
)
toast.success(`Resetting "${tableName}". Pipeline will ${pipelineAction} automatically.`)
},
onSettled: () => {
onRestartComplete?.()
onOpenChange(false)
},
onError: (error) => {
toast.error(`Failed to restart replication: ${error.message}`)
toast.error(`Failed to reset table: ${error.message}`)
},
})
@@ -74,47 +73,16 @@ export const RestartTableDialog = ({
})
}
const consequence = willCopyTable
? `Destination data for this table will be deleted, existing rows will sync again, and the pipeline will ${pipelineAction} automatically.`
: `Destination data for this table will be deleted. Initial sync is skipped for this table, so replication resumes with new changes only. The pipeline will ${pipelineAction} automatically.`
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Restart replication for <code className="text-code-inline">{tableName}</code>
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 text-sm">
<p>
This will restart replication for{' '}
<code className="text-code-inline">{tableName}</code> from scratch:
</p>
<ul className="list-disc list-inside space-y-1.5 pl-2">
{willCopyTable ? (
<li>
<strong>The table's initial sync will restart.</strong> Existing source rows
will be synced again. Data successfully processed during this initial sync is
billed again.
</li>
) : (
<li>
<strong>The table will skip initial sync.</strong> Replication will resume with
new changes only, without syncing existing source rows. There is no additional
initial sync charge.
</li>
)}
<li>
<strong>Existing downstream data will be deleted.</strong> Any replicated data for
this table will be removed.
</li>
<li>
<strong>All other tables remain untouched.</strong> Only this table is affected.
</li>
<li>
<strong>The pipeline will restart automatically.</strong> This is required to
apply this change.
</li>
</ul>
</div>
</AlertDialogDescription>
<AlertDialogTitle>Reset {tableName}</AlertDialogTitle>
<AlertDialogDescription>{consequence}</AlertDialogDescription>
</AlertDialogHeader>
<RestartCostEstimate
open={open}
@@ -126,7 +94,7 @@ export const RestartTableDialog = ({
<AlertDialogFooter>
<AlertDialogCancel disabled={isResetting}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={isResetting} onClick={handleReset} variant="warning">
{isResetting ? 'Restarting replication...' : 'Restart replication'}
{isResetting ? 'Resetting…' : 'Reset table'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>