RowMenu.tsx194 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import { ArrowUpCircle, Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react' |
| 3 | import { parseAsInteger, useQueryState } from 'nuqs' |
| 4 | import { toast } from 'sonner' |
| 5 | import { |
| 6 | Button, |
| 7 | DropdownMenu, |
| 8 | DropdownMenuContent, |
| 9 | DropdownMenuItem, |
| 10 | DropdownMenuSeparator, |
| 11 | DropdownMenuTrigger, |
| 12 | } from 'ui' |
| 13 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 14 | |
| 15 | import { |
| 16 | getStatusName, |
| 17 | PIPELINE_DISABLE_ALLOWED_FROM, |
| 18 | PIPELINE_ENABLE_ALLOWED_FROM, |
| 19 | } from './Pipeline.utils' |
| 20 | import { PipelineStatusName } from './Replication.constants' |
| 21 | import AlertError from '@/components/ui/AlertError' |
| 22 | import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query' |
| 23 | import { Pipeline } from '@/data/replication/pipelines-query' |
| 24 | import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper' |
| 25 | import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' |
| 26 | import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation' |
| 27 | import { |
| 28 | PipelineStatusRequestStatus, |
| 29 | usePipelineRequestStatus, |
| 30 | } from '@/state/replication-pipeline-request-status' |
| 31 | import type { ResponseError } from '@/types' |
| 32 | |
| 33 | interface RowMenuProps { |
| 34 | destinationId: number |
| 35 | pipeline: Pipeline | undefined |
| 36 | pipelineStatus?: ReplicationPipelineStatusData['status'] |
| 37 | error: ResponseError | null |
| 38 | isLoading: boolean |
| 39 | isError: boolean |
| 40 | hasUpdate?: boolean |
| 41 | onDeleteClick: () => void |
| 42 | onUpdateClick?: () => void |
| 43 | } |
| 44 | |
| 45 | export const RowMenu = ({ |
| 46 | destinationId, |
| 47 | pipeline, |
| 48 | pipelineStatus, |
| 49 | error, |
| 50 | isLoading, |
| 51 | isError, |
| 52 | hasUpdate = false, |
| 53 | onDeleteClick, |
| 54 | onUpdateClick, |
| 55 | }: RowMenuProps) => { |
| 56 | const { ref: projectRef } = useParams() |
| 57 | const statusName = getStatusName(pipelineStatus) |
| 58 | |
| 59 | const [, setEdit] = useQueryState( |
| 60 | 'edit', |
| 61 | parseAsInteger.withOptions({ history: 'push', clearOnDefault: true }) |
| 62 | ) |
| 63 | |
| 64 | const { mutateAsync: startPipeline } = useStartPipelineMutation() |
| 65 | const { mutateAsync: stopPipeline } = useStopPipelineMutation() |
| 66 | const { restartPipeline } = useRestartPipelineHelper() |
| 67 | const { getRequestStatus, setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus() |
| 68 | const requestStatus = pipeline?.id |
| 69 | ? getRequestStatus(pipeline.id) |
| 70 | : PipelineStatusRequestStatus.None |
| 71 | |
| 72 | // Show actions when not in a transitional state |
| 73 | const canPerformActions = |
| 74 | requestStatus === PipelineStatusRequestStatus.None && |
| 75 | statusName !== PipelineStatusName.STARTING && |
| 76 | [PipelineStatusName.STOPPED, PipelineStatusName.STARTED, PipelineStatusName.FAILED].includes( |
| 77 | statusName as PipelineStatusName |
| 78 | ) |
| 79 | |
| 80 | // Show both stop and restart for started/failed states |
| 81 | const showStopAndRestart = |
| 82 | canPerformActions && |
| 83 | (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED) |
| 84 | |
| 85 | // Show only start for stopped state |
| 86 | const showStart = canPerformActions && statusName === PipelineStatusName.STOPPED |
| 87 | |
| 88 | const onEnablePipeline = async () => { |
| 89 | if (!projectRef) return console.error('Project ref is required') |
| 90 | if (!pipeline) return toast.error('No pipeline found') |
| 91 | |
| 92 | try { |
| 93 | // Only show 'enabling' when transitioning from allowed states |
| 94 | if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) { |
| 95 | setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName) |
| 96 | } |
| 97 | await startPipeline({ projectRef, pipelineId: pipeline.id }) |
| 98 | } catch (error) { |
| 99 | setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) |
| 100 | toast.error(`Failed to start pipeline: ${(error as ResponseError).message}`) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | const onDisablePipeline = async () => { |
| 105 | if (!projectRef) return console.error('Project ref is required') |
| 106 | if (!pipeline) return toast.error('No pipeline found') |
| 107 | |
| 108 | try { |
| 109 | // Only show 'disabling' when transitioning from allowed states |
| 110 | if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) { |
| 111 | setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName) |
| 112 | } |
| 113 | await stopPipeline({ projectRef, pipelineId: pipeline.id }) |
| 114 | } catch (error) { |
| 115 | setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) |
| 116 | toast.error(`Failed to stop pipeline: ${(error as ResponseError).message}`) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | const onRestartPipeline = async () => { |
| 121 | if (!projectRef) return console.error('Project ref is required') |
| 122 | if (!pipeline) return toast.error('No pipeline found') |
| 123 | |
| 124 | try { |
| 125 | setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName) |
| 126 | await restartPipeline({ projectRef, pipelineId: pipeline.id }) |
| 127 | } catch (error) { |
| 128 | setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) |
| 129 | toast.error(`Failed to restart pipeline: ${(error as ResponseError).message}`) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | return ( |
| 134 | <div className="flex justify-end items-center space-x-2"> |
| 135 | {isLoading && <ShimmeringLoader />} |
| 136 | |
| 137 | {isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />} |
| 138 | |
| 139 | <DropdownMenu> |
| 140 | <DropdownMenuTrigger asChild> |
| 141 | <div className="relative"> |
| 142 | <Button type="default" className="px-1.5" icon={<MoreVertical />} /> |
| 143 | {hasUpdate && ( |
| 144 | <span className="absolute -top-0.5 -right-0.5 h-2 w-2 bg-brand rounded-full" /> |
| 145 | )} |
| 146 | </div> |
| 147 | </DropdownMenuTrigger> |
| 148 | |
| 149 | <DropdownMenuContent side="bottom" align="end" className="w-52"> |
| 150 | {hasUpdate && ( |
| 151 | <> |
| 152 | <DropdownMenuItem className="space-x-2" onClick={() => onUpdateClick?.()}> |
| 153 | <ArrowUpCircle size={14} /> |
| 154 | <p>Update available</p> |
| 155 | </DropdownMenuItem> |
| 156 | <DropdownMenuSeparator /> |
| 157 | </> |
| 158 | )} |
| 159 | {showStart && ( |
| 160 | <> |
| 161 | <DropdownMenuItem className="space-x-2" onClick={onEnablePipeline}> |
| 162 | <Play size={14} /> |
| 163 | <p>Start pipeline</p> |
| 164 | </DropdownMenuItem> |
| 165 | <DropdownMenuSeparator /> |
| 166 | </> |
| 167 | )} |
| 168 | {showStopAndRestart && ( |
| 169 | <> |
| 170 | <DropdownMenuItem className="space-x-2" onClick={onRestartPipeline}> |
| 171 | <RotateCcw size={14} /> |
| 172 | <p>Restart pipeline</p> |
| 173 | </DropdownMenuItem> |
| 174 | <DropdownMenuItem className="space-x-2" onClick={onDisablePipeline}> |
| 175 | <Pause size={14} /> |
| 176 | <p>Stop pipeline</p> |
| 177 | </DropdownMenuItem> |
| 178 | <DropdownMenuSeparator /> |
| 179 | </> |
| 180 | )} |
| 181 | |
| 182 | <DropdownMenuItem className="space-x-2" onClick={() => setEdit(destinationId)}> |
| 183 | <Edit size={14} /> |
| 184 | <p>Edit destination</p> |
| 185 | </DropdownMenuItem> |
| 186 | <DropdownMenuItem className="space-x-2" onClick={onDeleteClick}> |
| 187 | <Trash size={14} /> |
| 188 | <p>Delete destination</p> |
| 189 | </DropdownMenuItem> |
| 190 | </DropdownMenuContent> |
| 191 | </DropdownMenu> |
| 192 | </div> |
| 193 | ) |
| 194 | } |