Skip to content

Commit 7a33508

Browse files
authored
feat(scheduled-tasks): pause/resume, mutation toasts, submit guards, empty state (#5044)
* feat(scheduled-tasks): pause/resume, mutation toasts, submit guards, empty state - Toasts on every job mutation (create/update/delete/exclude/pause/resume) so failures are never silent - TaskModal stays open until the save persists; submit disabled in-flight to block double-submit - Pause/Resume recurring tasks via the context menu; paused occurrences render dimmed so they stay resumable - First-run empty state with a create CTA when the workspace has no tasks * fix(scheduled-tasks): return edit-modal save promise so the modal awaits persistence The edit TaskModal's onSubmit called updateTask without returning its promise, so the await resolved immediately — the modal closed before the save finished, failed edits didn't stay open, and a rejected mutateAsync went unhandled. Return the promise so the await tracks the real mutation (matching the create path). * refactor(scheduled-tasks): explicit return in edit-modal onSubmit for clarity * fix(scheduled-tasks): optimistic create insert + always-reset submit guard - Seed the created job into the workspace-list cache on success so the new task renders instantly and the first-run empty state never flashes between the success toast and the list refetch; onSettled still reconciles authoritatively. - Reset the modal's submitting flag in a finally so the submit button can never stick disabled if the modal is kept open. * revert(scheduled-tasks): drop first-run empty state Remove the empty-state prompt and its supporting hasTasks flag; with the empty state gone, also revert the create optimistic-insert (its only purpose was avoiding the empty-state flash) back to plain toast + list invalidation. * fix(scheduled-tasks): lock modal dismiss while a save is in flight Cancel, the header X, Escape, and overlay click all route through one guarded onOpenChange that no-ops while submitting, and the footer Cancel is disabled (cancelDisabled) — so an in-progress create/edit can't be abandoned mid-save and lose its draft. submitting moves up to TaskModal so the guard can read it. * fix(settings): align general appearance dropdowns to a uniform width Theme/Snap-to-grid (ChipSelect) hugged their content (~90px) while Timezone (ChipCombobox) was pinned to 260px, so the three read as a ragged column. Give all three a shared 240px trigger via fullWidth + a common width wrapper so they align as one column; menus match their triggers. No behavioral change — timezone keeps search, options and handlers are untouched. * docs(scheduled-tasks): document why the dismiss guard doesn't block the success-close * fix(scheduled-tasks): disable Delete while an edit save is in flight Delete bypasses the modal's dismiss guard (it closes via closeTask, not onOpenChange), so a click mid-save could run a delete against the same task as the in-flight update. Disable it while submitting, matching Cancel. * refactor(scheduled-tasks): TSDoc over inline comments in task modal Move the Delete-lock rationale to a TSDoc block on secondaryActions, and restructure handleSubmit to a boolean persist result so the failure path is self-evident — removing the inline comments and the empty catch block. * fix(scheduled-tasks): gate submit on a synchronous ref to fully block double-submit The submitting state flag only reflects after a re-render, so two same-tick invocations (Enter racing the click) could both pass a state-based guard and fire two mutations. A submittingRef flips synchronously, so the second invocation is rejected before it can submit again; the state still drives the button/cancel UI.
1 parent 6c56a21 commit 7a33508

9 files changed

Lines changed: 318 additions & 63 deletions

File tree

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ interface CalendarEventChipProps {
2424
* details modal carries the state. The pill is the grid's real `<button>` (its
2525
* parent cells are plain clickable `<div>`s), so tasks are the tab-reachable
2626
* elements; clicks stop propagating so the cell underneath doesn't also open
27-
* the create modal.
27+
* the create modal. A paused task (`task.disabled`) renders dimmed — the one
28+
* status the pill signals visually, since a paused task can sit on the calendar
29+
* indefinitely without running.
2830
*/
2931
export function CalendarEventChip({
3032
event,
@@ -49,6 +51,7 @@ export function CalendarEventChip({
4951
chipContentGap,
5052
chipPrimaryFillTokens,
5153
'hover-hover:bg-[var(--text-body)] dark:hover-hover:bg-[var(--text-secondary)]',
54+
event.task.disabled && 'opacity-45',
5255
className
5356
)}
5457
>

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
DropdownMenuSeparator,
88
DropdownMenuTrigger,
99
} from '@/components/emcn'
10-
import { Duplicate as DuplicateIcon, Pencil, Trash } from '@/components/emcn/icons'
10+
import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@/components/emcn/icons'
1111
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
1212

1313
interface TaskContextMenuProps {
@@ -19,13 +19,18 @@ interface TaskContextMenuProps {
1919
onEdit: () => void
2020
/** Opens a new-task modal pre-filled from this task. */
2121
onDuplicate: () => void
22+
/** Pauses an active recurring task — suspends its future runs. */
23+
onPause: () => void
24+
/** Resumes a paused recurring task. */
25+
onResume: () => void
2226
onDelete: () => void
2327
}
2428

2529
/**
2630
* Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
27-
* edited or deleted; any task can be duplicated into a new one. Finished tasks
28-
* open their read-only record on click, so the menu only offers Duplicate.
31+
* edited or deleted, and recurring ones paused or resumed; any task can be
32+
* duplicated into a new one. Finished tasks open their read-only record on
33+
* click, so the menu only offers Duplicate.
2934
*/
3035
export function TaskContextMenu({
3136
isOpen,
@@ -34,9 +39,13 @@ export function TaskContextMenu({
3439
task,
3540
onEdit,
3641
onDuplicate,
42+
onPause,
43+
onResume,
3744
onDelete,
3845
}: TaskContextMenuProps) {
3946
const isUpcoming = task?.status === 'pending'
47+
/** Pause/Resume applies to recurring tasks only — one-time tasks carry no cadence. */
48+
const canPauseResume = isUpcoming && task?.recurring === true
4049

4150
return (
4251
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -67,6 +76,18 @@ export function TaskContextMenu({
6776
<Pencil />
6877
Edit
6978
</DropdownMenuItem>
79+
{canPauseResume &&
80+
(task?.disabled ? (
81+
<DropdownMenuItem onSelect={onResume}>
82+
<Play />
83+
Resume
84+
</DropdownMenuItem>
85+
) : (
86+
<DropdownMenuItem onSelect={onPause}>
87+
<Pause />
88+
Pause
89+
</DropdownMenuItem>
90+
))}
7091
<DropdownMenuItem onSelect={onDuplicate}>
7192
<DuplicateIcon />
7293
Duplicate

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,12 @@ interface TaskModalProps {
105105
edit?: TaskEditSeed | null
106106
/** Pre-fill for a create (duplicate): opens in create mode with every field copied. */
107107
prefill?: TaskPrefill | null
108-
/** Receives the captured draft on submit (create and save alike). */
109-
onSubmit: (draft: TaskDraft) => void
108+
/**
109+
* Receives the captured draft on submit (create and save alike). May return a
110+
* promise — the modal awaits it, keeping itself open until the task persists
111+
* and closing only on success, so a failed save never silently discards the draft.
112+
*/
113+
onSubmit: (draft: TaskDraft) => void | Promise<void>
110114
/** Asks the parent to start the delete flow (which handles the recurring this/all choice). */
111115
onRequestDelete?: () => void
112116
}
@@ -127,25 +131,53 @@ export function TaskModal({
127131
onSubmit,
128132
onRequestDelete,
129133
}: TaskModalProps) {
134+
const [submitting, setSubmitting] = useState(false)
135+
136+
/**
137+
* While a save is in flight, swallow every dismiss path — Cancel, header X,
138+
* Escape, and overlay click all route through this one handler — so an
139+
* in-progress create/edit can't be abandoned and lose its draft. `submitting`
140+
* lives here (not in the unmounted-on-close content) so this guard can see it.
141+
*
142+
* The programmatic close on a *successful* submit is intentionally NOT blocked:
143+
* `handleSubmit` runs in the pre-submit render where `submitting` was still
144+
* false, so its `close()` resolves to that render's handler and passes through,
145+
* while user dismisses fire from the current (submitting) render and are caught
146+
* here. Keep `submitting` as render state — moving it to a ref or memoizing this
147+
* handler with `submitting` in deps would make the success-close start blocking.
148+
*/
149+
const handleOpenChange = (next: boolean) => {
150+
if (!next && submitting) return
151+
onOpenChange(next)
152+
}
153+
130154
return (
131155
<ChipModal
132156
open={open}
133-
onOpenChange={onOpenChange}
157+
onOpenChange={handleOpenChange}
134158
size='lg'
135159
srTitle={edit ? 'Edit scheduled task' : 'New scheduled task'}
136160
>
137161
<TaskModalContent
138-
onOpenChange={onOpenChange}
162+
onOpenChange={handleOpenChange}
139163
slot={slot}
140164
edit={edit}
141165
prefill={prefill}
142166
onSubmit={onSubmit}
143167
onRequestDelete={onRequestDelete}
168+
submitting={submitting}
169+
setSubmitting={setSubmitting}
144170
/>
145171
</ChipModal>
146172
)
147173
}
148174

175+
interface TaskModalContentProps extends Omit<TaskModalProps, 'open'> {
176+
/** Whether a save is in flight — owned by {@link TaskModal} so the dismiss guard can read it. */
177+
submitting: boolean
178+
setSubmitting: (submitting: boolean) => void
179+
}
180+
149181
/**
150182
* Inner content, mounted only while the dialog is open (the Radix portal
151183
* unmounts closed content). Holding the editor here keeps its mention-data
@@ -158,7 +190,9 @@ function TaskModalContent({
158190
prefill,
159191
onSubmit,
160192
onRequestDelete,
161-
}: Omit<TaskModalProps, 'open'>) {
193+
submitting,
194+
setSubmitting,
195+
}: TaskModalContentProps) {
162196
const { workspaceId } = useParams<{ workspaceId: string }>()
163197
const source = edit ?? prefill
164198
const accountTimezone = useTimezone()
@@ -190,6 +224,14 @@ function TaskModalContent({
190224
() => source?.recurrence ?? DEFAULT_RECURRENCE
191225
)
192226
const launchEditedRef = useRef(false)
227+
/**
228+
* Synchronous mirror of `submitting` that gates {@link handleSubmit}. The
229+
* `submitting` state only reflects after a re-render, so two invocations in the
230+
* same tick (Enter racing the click) could both pass a state-based guard; the
231+
* ref flips immediately, so the second is rejected before it can fire a second
232+
* mutation.
233+
*/
234+
const submittingRef = useRef(false)
193235

194236
/**
195237
* Re-seed a blank create's default launch when the effective zone resolves
@@ -223,22 +265,52 @@ function TaskModalContent({
223265

224266
const promptText = editor.value.trim()
225267

226-
const handleSubmit = () => {
227-
if (!promptText || isPastLaunch) return
228-
onSubmit({
229-
prompt: editor.getPlainValue().trim(),
230-
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
231-
launchDate,
232-
launchTime,
233-
timezone,
234-
recurrence,
235-
})
236-
close()
268+
/**
269+
* Submits the draft and waits for it to persist. The synchronous
270+
* {@link submittingRef} guard blocks a double-submit (Enter racing the click).
271+
* The modal closes only when the save resolves; a rejection leaves it open so
272+
* the draft survives — the mutation hook already surfaces the error via toast,
273+
* so it is swallowed here rather than duplicated. Both the ref and the
274+
* `submitting` state are always cleared, so the button can never stick disabled
275+
* while the modal stays open.
276+
*/
277+
const handleSubmit = async () => {
278+
if (!promptText || isPastLaunch || submittingRef.current) return
279+
submittingRef.current = true
280+
setSubmitting(true)
281+
const persisted = await Promise.resolve(
282+
onSubmit({
283+
prompt: editor.getPlainValue().trim(),
284+
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
285+
launchDate,
286+
launchTime,
287+
timezone,
288+
recurrence,
289+
})
290+
)
291+
.then(() => true)
292+
.catch(() => false)
293+
submittingRef.current = false
294+
setSubmitting(false)
295+
if (persisted) close()
237296
}
238297

298+
/**
299+
* Footer secondary actions. Delete is disabled while `submitting` because it
300+
* bypasses the dismiss guard — it closes the modal via `closeTask`, not the
301+
* guarded `onOpenChange` — so without the lock an in-flight edit and a delete
302+
* could run against the same task at once.
303+
*/
239304
const secondaryActions: ChipModalFooterSlotAction[] = [
240305
...(edit && onRequestDelete
241-
? [{ label: 'Delete', variant: 'destructive' as const, onClick: onRequestDelete }]
306+
? [
307+
{
308+
label: 'Delete',
309+
variant: 'destructive' as const,
310+
onClick: onRequestDelete,
311+
disabled: submitting,
312+
},
313+
]
242314
: []),
243315
{
244316
custom: (
@@ -268,11 +340,12 @@ function TaskModalContent({
268340
</ChipModalPromptBody>
269341
<ChipModalFooter
270342
onCancel={close}
343+
cancelDisabled={submitting}
271344
secondaryActions={secondaryActions}
272345
primaryAction={{
273-
label: edit ? 'Save' : 'Schedule',
346+
label: submitting ? (edit ? 'Saving...' : 'Scheduling...') : edit ? 'Save' : 'Schedule',
274347
onClick: handleSubmit,
275-
disabled: !promptText || isPastLaunch,
348+
disabled: !promptText || isPastLaunch || submitting,
276349
disabledTooltip: isPastLaunch ? PAST_LAUNCH_MESSAGE : undefined,
277350
}}
278351
/>

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
import {
2323
useCreateSchedule,
2424
useDeleteSchedule,
25+
useDisableSchedule,
2526
useExcludeOccurrence,
27+
useResumeSchedule,
2628
useUpdateSchedule,
2729
useWorkspaceSchedules,
2830
} from '@/hooks/queries/schedules'
@@ -91,12 +93,18 @@ export interface UseScheduledTasksReturn {
9193
closeTask: () => void
9294
/** Recovers the modal's edit seed (recurrence, launch) from a task's schedule. */
9395
editSeedFor: (task: ScheduledTask) => TaskEditSeed | null
94-
createTask: (draft: TaskDraft) => void
95-
updateTask: (scheduleId: string, draft: TaskDraft) => void
96+
/** Resolves once the create persists; rejects on failure so the modal stays open. */
97+
createTask: (draft: TaskDraft) => Promise<void>
98+
/** Resolves once the edit persists; rejects on failure so the modal stays open. */
99+
updateTask: (scheduleId: string, draft: TaskDraft) => Promise<void>
96100
/** Deletes the whole task (one-time or the entire recurring series). */
97101
deleteTask: (scheduleId: string) => void
98102
/** Deletes a single occurrence of a recurring task. */
99103
deleteOccurrence: (scheduleId: string, occurrence: Date) => void
104+
/** Pauses a recurring task — suspends future runs until resumed. */
105+
pauseTask: (scheduleId: string) => void
106+
/** Resumes a paused recurring task, recomputing its next run from the cron. */
107+
resumeTask: (scheduleId: string) => void
100108
}
101109

102110
/**
@@ -115,6 +123,8 @@ export function useScheduledTasks({
115123
const updateSchedule = useUpdateSchedule()
116124
const deleteSchedule = useDeleteSchedule()
117125
const excludeOccurrence = useExcludeOccurrence()
126+
const disableSchedule = useDisableSchedule()
127+
const resumeSchedule = useResumeSchedule()
118128

119129
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
120130

@@ -157,15 +167,16 @@ export function useScheduledTasks({
157167
)
158168

159169
const createTask = useCallback(
160-
(draft: TaskDraft) => createSchedule.mutate(draftToCreateBody(draft, workspaceId)),
170+
async (draft: TaskDraft) => {
171+
await createSchedule.mutateAsync(draftToCreateBody(draft, workspaceId))
172+
},
161173
// eslint-disable-next-line react-hooks/exhaustive-deps
162174
[workspaceId]
163175
)
164176

165177
const updateTask = useCallback(
166-
(scheduleId: string, draft: TaskDraft) => {
167-
updateSchedule.mutate({ scheduleId, workspaceId, ...draftToUpdateBody(draft) })
168-
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
178+
async (scheduleId: string, draft: TaskDraft) => {
179+
await updateSchedule.mutateAsync({ scheduleId, workspaceId, ...draftToUpdateBody(draft) })
169180
},
170181
// eslint-disable-next-line react-hooks/exhaustive-deps
171182
[workspaceId]
@@ -189,6 +200,24 @@ export function useScheduledTasks({
189200
[workspaceId]
190201
)
191202

203+
const pauseTask = useCallback(
204+
(scheduleId: string) => {
205+
disableSchedule.mutate({ scheduleId, workspaceId })
206+
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
207+
},
208+
// eslint-disable-next-line react-hooks/exhaustive-deps
209+
[workspaceId]
210+
)
211+
212+
const resumeTask = useCallback(
213+
(scheduleId: string) => {
214+
resumeSchedule.mutate({ scheduleId, workspaceId })
215+
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
216+
},
217+
// eslint-disable-next-line react-hooks/exhaustive-deps
218+
[workspaceId]
219+
)
220+
192221
return {
193222
isLoading,
194223
eventsByDay,
@@ -200,5 +229,7 @@ export function useScheduledTasks({
200229
updateTask,
201230
deleteTask,
202231
deleteOccurrence,
232+
pauseTask,
233+
resumeTask,
203234
}
204235
}

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ export function ScheduledTasks() {
113113
if (contextTask) handleOpenTask(contextTask)
114114
}, [contextTask, handleOpenTask])
115115

116+
const handlePauseContextTask = useCallback(() => {
117+
if (contextTask) tasks.pauseTask(contextTask.scheduleId)
118+
// eslint-disable-next-line react-hooks/exhaustive-deps
119+
}, [contextTask])
120+
121+
const handleResumeContextTask = useCallback(() => {
122+
if (contextTask) tasks.resumeTask(contextTask.scheduleId)
123+
// eslint-disable-next-line react-hooks/exhaustive-deps
124+
}, [contextTask])
125+
116126
const handleContentContextMenu = useCallback(
117127
(e: React.MouseEvent) => {
118128
const target = e.target as HTMLElement
@@ -175,6 +185,8 @@ export function ScheduledTasks() {
175185
task={contextTask}
176186
onEdit={openContextTask}
177187
onDuplicate={handleDuplicate}
188+
onPause={handlePauseContextTask}
189+
onResume={handleResumeContextTask}
178190
onDelete={() => setDeletingTask(contextTask)}
179191
/>
180192

@@ -205,7 +217,7 @@ export function ScheduledTasks() {
205217
}}
206218
edit={editSeed}
207219
onSubmit={(draft) => {
208-
if (editTask) tasks.updateTask(editTask.scheduleId, draft)
220+
if (editTask) return tasks.updateTask(editTask.scheduleId, draft)
209221
}}
210222
onRequestDelete={() => {
211223
setDeletingTask(editTask)

0 commit comments

Comments
 (0)