RowMenu.tsx194 lines · main
1import { useParams } from 'common'
2import { ArrowUpCircle, Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react'
3import { parseAsInteger, useQueryState } from 'nuqs'
4import { toast } from 'sonner'
5import {
6 Button,
7 DropdownMenu,
8 DropdownMenuContent,
9 DropdownMenuItem,
10 DropdownMenuSeparator,
11 DropdownMenuTrigger,
12} from 'ui'
13import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
14
15import {
16 getStatusName,
17 PIPELINE_DISABLE_ALLOWED_FROM,
18 PIPELINE_ENABLE_ALLOWED_FROM,
19} from './Pipeline.utils'
20import { PipelineStatusName } from './Replication.constants'
21import AlertError from '@/components/ui/AlertError'
22import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
23import { Pipeline } from '@/data/replication/pipelines-query'
24import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper'
25import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
26import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
27import {
28 PipelineStatusRequestStatus,
29 usePipelineRequestStatus,
30} from '@/state/replication-pipeline-request-status'
31import type { ResponseError } from '@/types'
32
33interface 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
45export 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}