Skip to content

Commit 6a0402d

Browse files
ralph: ralph-fix-global-schedule-repo-selector-broken-mutations completed after 4 iterations (#168)
1 parent cf094d7 commit 6a0402d

4 files changed

Lines changed: 109 additions & 50 deletions

File tree

frontend/src/components/schedules/ScheduleJobDialog.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { CreateScheduleJobRequest, PromptTemplate, ScheduleJob } from '@ope
44
import { getProvidersWithModels } from '@/api/providers'
55
import { createOpenCodeClient } from '@/api/opencode'
66
import { settingsApi } from '@/api/settings'
7+
import { listRepos } from '@/api/repos'
8+
import type { Repo } from '@/api/types'
79
import { OPENCODE_API_ENDPOINT } from '@/config'
810
import { Badge } from '@/components/ui/badge'
911
import { Button } from '@/components/ui/button'
@@ -26,6 +28,7 @@ import {
2628
type SchedulePreset,
2729
weekdayOptions,
2830
} from '@/components/schedules/schedule-utils'
31+
import { getRepoDisplayName } from '@/lib/utils'
2932
import { Check, Info, Loader2, Pencil, Plus, Sparkles, Trash2 } from 'lucide-react'
3033
import { usePromptTemplates, useDeletePromptTemplate } from '@/hooks/usePromptTemplates'
3134
import { PromptTemplateDialog } from './PromptTemplateDialog'
@@ -37,6 +40,9 @@ type ScheduleJobDialogProps = {
3740
job?: ScheduleJob
3841
isSaving: boolean
3942
onSubmit: (data: CreateScheduleJobRequest) => void
43+
showRepoSelector?: boolean
44+
repoId?: number
45+
onRepoChange?: (repoId: number | undefined) => void
4046
}
4147

4248
function InfoHint({ text }: { text: string }) {
@@ -51,7 +57,7 @@ function InfoHint({ text }: { text: string }) {
5157
)
5258
}
5359

54-
export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit }: ScheduleJobDialogProps) {
60+
export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, showRepoSelector, repoId: selectedRepoId, onRepoChange }: ScheduleJobDialogProps) {
5561
const [schedulePreset, setSchedulePreset] = useState<SchedulePreset>('interval')
5662
const [name, setName] = useState('')
5763
const [description, setDescription] = useState('')
@@ -112,6 +118,24 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit
112118
staleTime: 5 * 60 * 1000,
113119
})
114120

121+
const { data: repos = [] } = useQuery<Repo[]>({
122+
queryKey: ['repos'],
123+
queryFn: listRepos,
124+
enabled: open && !!showRepoSelector,
125+
staleTime: 5 * 60 * 1000,
126+
})
127+
128+
const repoOptions = useMemo<ComboboxOption[]>(() =>
129+
repos
130+
.filter((repo) => repo.cloneStatus === 'ready')
131+
.map((repo) => ({
132+
value: repo.id.toString(),
133+
label: getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath),
134+
description: repo.localPath,
135+
})),
136+
[repos]
137+
)
138+
115139
const modelOptions = useMemo<ComboboxOption[]>(() => {
116140
const configuredModels: ComboboxOption[] = []
117141
const configuredValues = new Set<string>()
@@ -269,6 +293,18 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit
269293

270294
<TabsContent value="basics" className="mt-0 min-h-0 flex-1 overflow-y-auto pt-4 pb-5">
271295
<div className="space-y-4">
296+
{showRepoSelector && !job && (
297+
<div className="space-y-2">
298+
<Label>Repository</Label>
299+
<Combobox
300+
value={selectedRepoId?.toString() ?? ''}
301+
onChange={(value) => onRepoChange?.(value ? Number(value) : undefined)}
302+
options={repoOptions}
303+
placeholder="Select a repository"
304+
allowCustomValue={false}
305+
/>
306+
</div>
307+
)}
272308
<div className="grid gap-4 sm:grid-cols-2">
273309
<div className="space-y-2">
274310
<Label htmlFor="schedule-name">Name</Label>
@@ -637,7 +673,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit
637673

638674
<div className="mt-0 shrink-0 border-t border-border px-3 sm:px-6 py-4 flex flex-row gap-2 sm:justify-end">
639675
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving} className="flex-1 sm:flex-none">Cancel</Button>
640-
<Button onClick={handleSubmit} disabled={isSaving || !name.trim() || !prompt.trim() || isScheduleConfigInvalid} className="flex-1 sm:flex-none">
676+
<Button onClick={handleSubmit} disabled={isSaving || !name.trim() || !prompt.trim() || isScheduleConfigInvalid || (!!showRepoSelector && !job && !selectedRepoId)} className="flex-1 sm:flex-none">
641677
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
642678
{isSaving ? 'Saving...' : job ? 'Save changes' : 'Create schedule'}
643679
</Button>

frontend/src/hooks/useSchedules.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,17 @@ export function useCreateRepoSchedule(repoId: number | undefined) {
7777
const queryClient = useQueryClient()
7878

7979
return useMutation({
80-
mutationFn: async (data: CreateScheduleJobRequest) => {
81-
const response = await createRepoSchedule(repoId!, data)
80+
mutationFn: async ({ repoId: callRepoId, data }: { repoId?: number; data: CreateScheduleJobRequest }) => {
81+
const resolvedRepoId = callRepoId ?? repoId
82+
const response = await createRepoSchedule(resolvedRepoId!, data)
8283
return response.job
8384
},
84-
onSuccess: () => {
85-
queryClient.invalidateQueries({ queryKey: ['repo-schedules', repoId] })
85+
onSuccess: (__variables) => {
86+
queryClient.invalidateQueries({ queryKey: ['repo-schedules', __variables.repoId ?? repoId] })
87+
queryClient.invalidateQueries({ queryKey: ['all-schedules'] })
8688
showToast.success('Schedule created')
8789
},
88-
onError: (error) => {
90+
onError: (error: unknown) => {
8991
showToast.error(`Failed to create schedule: ${error instanceof Error ? error.message : String(error)}`)
9092
},
9193
})
@@ -95,16 +97,18 @@ export function useUpdateRepoSchedule(repoId: number | undefined) {
9597
const queryClient = useQueryClient()
9698

9799
return useMutation({
98-
mutationFn: async ({ jobId, data }: { jobId: number; data: UpdateScheduleJobRequest }) => {
99-
const response = await updateRepoSchedule(repoId!, jobId, data)
100+
mutationFn: async ({ repoId: callRepoId, jobId, data }: { repoId?: number; jobId: number; data: UpdateScheduleJobRequest }) => {
101+
const resolvedRepoId = callRepoId ?? repoId
102+
const response = await updateRepoSchedule(resolvedRepoId!, jobId, data)
100103
return response.job
101104
},
102-
onSuccess: (_, variables) => {
103-
queryClient.invalidateQueries({ queryKey: ['repo-schedules', repoId] })
104-
queryClient.invalidateQueries({ queryKey: ['repo-schedule', repoId, variables.jobId] })
105+
onSuccess: (__variables) => {
106+
queryClient.invalidateQueries({ queryKey: ['repo-schedules', __variables.repoId ?? repoId] })
107+
queryClient.invalidateQueries({ queryKey: ['repo-schedule', __variables.repoId ?? repoId, __variables.jobId] })
108+
queryClient.invalidateQueries({ queryKey: ['all-schedules'] })
105109
showToast.success('Schedule updated')
106110
},
107-
onError: (error) => {
111+
onError: (error: unknown) => {
108112
showToast.error(`Failed to update schedule: ${error instanceof Error ? error.message : String(error)}`)
109113
},
110114
})
@@ -114,12 +118,16 @@ export function useDeleteRepoSchedule(repoId: number | undefined) {
114118
const queryClient = useQueryClient()
115119

116120
return useMutation({
117-
mutationFn: (jobId: number) => deleteRepoSchedule(repoId!, jobId),
118-
onSuccess: () => {
119-
queryClient.invalidateQueries({ queryKey: ['repo-schedules', repoId] })
121+
mutationFn: ({ repoId: callRepoId, jobId }: { repoId?: number; jobId: number }) => {
122+
const resolvedRepoId = callRepoId ?? repoId
123+
return deleteRepoSchedule(resolvedRepoId!, jobId)
124+
},
125+
onSuccess: (__variables) => {
126+
queryClient.invalidateQueries({ queryKey: ['repo-schedules', __variables.repoId ?? repoId] })
127+
queryClient.invalidateQueries({ queryKey: ['all-schedules'] })
120128
showToast.success('Schedule deleted')
121129
},
122-
onError: (error) => {
130+
onError: (error: unknown) => {
123131
showToast.error(`Failed to delete schedule: ${error instanceof Error ? error.message : String(error)}`)
124132
},
125133
})
@@ -129,18 +137,20 @@ export function useRunRepoSchedule(repoId: number | undefined) {
129137
const queryClient = useQueryClient()
130138

131139
return useMutation({
132-
mutationFn: async (jobId: number) => {
133-
const response = await runRepoSchedule(repoId!, jobId)
140+
mutationFn: async ({ repoId: callRepoId, jobId }: { repoId?: number; jobId: number }) => {
141+
const resolvedRepoId = callRepoId ?? repoId
142+
const response = await runRepoSchedule(resolvedRepoId!, jobId)
134143
return response.run
135144
},
136-
onSuccess: (run) => {
137-
queryClient.invalidateQueries({ queryKey: ['repo-schedules', repoId] })
138-
queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', repoId, run.jobId] })
139-
queryClient.invalidateQueries({ queryKey: ['repo-schedule', repoId, run.jobId] })
140-
queryClient.invalidateQueries({ queryKey: ['repo-schedule-run', repoId, run.jobId, run.id] })
145+
onSuccess: (run, variables) => {
146+
queryClient.invalidateQueries({ queryKey: ['repo-schedules', variables.repoId ?? repoId] })
147+
queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', variables.repoId ?? repoId, run.jobId] })
148+
queryClient.invalidateQueries({ queryKey: ['repo-schedule', variables.repoId ?? repoId, run.jobId] })
149+
queryClient.invalidateQueries({ queryKey: ['repo-schedule-run', variables.repoId ?? repoId, run.jobId, run.id] })
150+
queryClient.invalidateQueries({ queryKey: ['all-schedules'] })
141151
showToast.success(run.status === 'running' ? 'Schedule started' : 'Schedule run completed')
142152
},
143-
onError: (error) => {
153+
onError: (error: unknown) => {
144154
showToast.error(`Failed to run schedule: ${error instanceof Error ? error.message : String(error)}`)
145155
},
146156
})

frontend/src/pages/GlobalSchedules.tsx

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useMemo } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { useAllSchedules } from '@/hooks/useSchedules'
4-
import { useDeleteRepoSchedule, useRunRepoSchedule, useUpdateRepoSchedule } from '@/hooks/useSchedules'
4+
import { useDeleteRepoSchedule, useRunRepoSchedule, useUpdateRepoSchedule, useCreateRepoSchedule } from '@/hooks/useSchedules'
55
import { ScheduleJobDialog } from '@/components/schedules'
66
import type { CreateScheduleJobRequest } from '@opencode-manager/shared/types'
77
import { toUpdateScheduleRequest, formatScheduleShortLabel, getJobStatusTone, formatTimestamp } from '@/components/schedules/schedule-utils'
@@ -24,14 +24,16 @@ export function GlobalSchedules() {
2424
const navigate = useNavigate()
2525
const [dialogOpen, setDialogOpen] = useState(false)
2626
const [editingJob, setEditingJob] = useState<ScheduleJobWithRepo | null>(null)
27-
const [deleteJobId, setDeleteJobId] = useState<number | null>(null)
27+
const [deletingJob, setDeletingJob] = useState<ScheduleJobWithRepo | null>(null)
28+
const [selectedRepoId, setSelectedRepoId] = useState<number | undefined>(undefined)
2829
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
2930
const [scheduleModeFilter, setScheduleModeFilter] = useState<ScheduleModeFilter>('all')
3031
const [repoFilter, setRepoFilter] = useState<string>('all')
3132
const [sortOption, setSortOption] = useState<SortOption>('nextRun')
3233

3334
const { data: jobs = [], isLoading, error } = useAllSchedules()
3435

36+
const createMutation = useCreateRepoSchedule(undefined)
3537
const deleteMutation = useDeleteRepoSchedule(undefined)
3638
const runMutation = useRunRepoSchedule(undefined)
3739
const updateMutation = useUpdateRepoSchedule(undefined)
@@ -124,44 +126,51 @@ export function GlobalSchedules() {
124126
], [])
125127

126128
const handleDelete = () => {
127-
if (deleteJobId === null) {
129+
if (!deletingJob) {
128130
return
129131
}
130132

131-
deleteMutation.mutate(deleteJobId, {
132-
onSuccess: () => {
133-
setDeleteJobId(null)
134-
},
135-
})
133+
deleteMutation.mutate(
134+
{ repoId: deletingJob.repoId, jobId: deletingJob.id },
135+
{ onSuccess: () => setDeletingJob(null) }
136+
)
136137
}
137138

138139
const handleToggleEnabled = (job: ScheduleJobWithRepo) => {
139140
updateMutation.mutate({
141+
repoId: job.repoId,
140142
jobId: job.id,
141143
data: { enabled: !job.enabled },
142144
})
143145
}
144146

145147
const handleRunNow = (job: ScheduleJobWithRepo) => {
146-
runMutation.mutate(job.id)
148+
runMutation.mutate({ repoId: job.repoId, jobId: job.id })
147149
}
148150

149151
const handleEdit = (job: ScheduleJobWithRepo) => {
150152
setEditingJob(job)
151153
setDialogOpen(true)
152154
}
153155

154-
const handleCreate = () => {
155-
setDialogOpen(false)
156+
const handleCreate = (data: CreateScheduleJobRequest) => {
157+
if (!selectedRepoId) return
158+
createMutation.mutate(
159+
{ repoId: selectedRepoId, data },
160+
{
161+
onSuccess: () => {
162+
setDialogOpen(false)
163+
setSelectedRepoId(undefined)
164+
},
165+
}
166+
)
156167
}
157168

158169
const handleUpdate = (data: CreateScheduleJobRequest) => {
159-
if (!editingJob) {
160-
return
161-
}
162-
170+
if (!editingJob) return
163171
updateMutation.mutate(
164172
{
173+
repoId: editingJob.repoId,
165174
jobId: editingJob.id,
166175
data: toUpdateScheduleRequest(data),
167176
},
@@ -221,15 +230,15 @@ export function GlobalSchedules() {
221230
</Badge>
222231
<Header.Actions>
223232
<Button
224-
onClick={() => { setEditingJob(null); setDialogOpen(true) }}
233+
onClick={() => { setEditingJob(null); setSelectedRepoId(undefined); setDialogOpen(true) }}
225234
size="sm"
226235
className="hidden sm:flex"
227236
>
228237
<Plus className="w-4 h-4 mr-2" />
229238
New Schedule
230239
</Button>
231240
<Button
232-
onClick={() => { setEditingJob(null); setDialogOpen(true) }}
241+
onClick={() => { setEditingJob(null); setSelectedRepoId(undefined); setDialogOpen(true) }}
233242
size="sm"
234243
className="sm:hidden"
235244
>
@@ -535,7 +544,7 @@ export function GlobalSchedules() {
535544
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
536545
onClick={(e) => {
537546
e.stopPropagation()
538-
setDeleteJobId(job.id)
547+
setDeletingJob(job)
539548
}}
540549
>
541550
<Trash2 className="h-3.5 w-3.5" />
@@ -554,18 +563,22 @@ export function GlobalSchedules() {
554563
setDialogOpen(open)
555564
if (!open) {
556565
setEditingJob(null)
566+
setSelectedRepoId(undefined)
557567
}
558568
}}
559569
job={editingJob ?? undefined}
560-
isSaving={updateMutation.isPending}
570+
isSaving={createMutation.isPending || updateMutation.isPending}
561571
onSubmit={editingJob ? handleUpdate : handleCreate}
572+
showRepoSelector
573+
repoId={selectedRepoId}
574+
onRepoChange={setSelectedRepoId}
562575
/>
563576

564577
<DeleteDialog
565-
open={deleteJobId !== null}
566-
onOpenChange={(open) => !open && setDeleteJobId(null)}
578+
open={deletingJob !== null}
579+
onOpenChange={(open) => !open && setDeletingJob(null)}
567580
onConfirm={handleDelete}
568-
onCancel={() => setDeleteJobId(null)}
581+
onCancel={() => setDeletingJob(null)}
569582
title="Delete Schedule"
570583
description="This removes the job definition and all recorded run history for it."
571584
isDeleting={deleteMutation.isPending}

frontend/src/pages/Schedules.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function Schedules() {
103103
const hasJobs = (jobs?.length ?? 0) > 0
104104

105105
const handleCreate = (data: CreateScheduleJobRequest) => {
106-
createMutation.mutate(data, {
106+
createMutation.mutate({ data }, {
107107
onSuccess: (job) => {
108108
setSelectedJobId(job.id)
109109
setDialogOpen(false)
@@ -133,7 +133,7 @@ export function Schedules() {
133133
return
134134
}
135135

136-
deleteMutation.mutate(deleteJobId, {
136+
deleteMutation.mutate({ jobId: deleteJobId }, {
137137
onSuccess: () => {
138138
if (selectedJobId === deleteJobId) {
139139
setSelectedJobId(null)
@@ -159,7 +159,7 @@ export function Schedules() {
159159
return
160160
}
161161

162-
runMutation.mutate(selectedJob.id, {
162+
runMutation.mutate({ jobId: selectedJob.id }, {
163163
onSuccess: (run) => {
164164
setSelectedRunId(run.id)
165165
},

0 commit comments

Comments
 (0)