feat(schedules): add edit support with context menu for standalone jobs

This commit is contained in:
Waleed Latif
2026-03-08 18:41:17 -07:00
parent 6295fd1a11
commit 4946571922
8 changed files with 546 additions and 65 deletions

View File

@@ -15,9 +15,19 @@ const logger = createLogger('ScheduleAPI')
export const dynamic = 'force-dynamic'
const scheduleUpdateSchema = z.object({
action: z.enum(['reactivate', 'disable']),
})
const scheduleUpdateSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('reactivate') }),
z.object({ action: z.literal('disable') }),
z.object({
action: z.literal('update'),
title: z.string().min(1).optional(),
prompt: z.string().min(1).optional(),
cronExpression: z.string().optional(),
timezone: z.string().optional(),
lifecycle: z.enum(['persistent', 'until_complete']).optional(),
maxRuns: z.number().nullable().optional(),
}),
])
type ScheduleRow = {
id: string
@@ -140,6 +150,66 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ message: 'Schedule disabled successfully' })
}
if (action === 'update') {
if (schedule.sourceType !== 'job') {
return NextResponse.json(
{ error: 'Only standalone job schedules can be edited' },
{ status: 400 }
)
}
const updates = validation.data
const setFields: Record<string, unknown> = { updatedAt: new Date() }
if (updates.title !== undefined) setFields.jobTitle = updates.title.trim()
if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim()
if (updates.timezone !== undefined) setFields.timezone = updates.timezone
if (updates.lifecycle !== undefined) {
setFields.lifecycle = updates.lifecycle
if (updates.lifecycle === 'persistent') {
setFields.maxRuns = null
}
}
if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns
if (updates.cronExpression !== undefined) {
const tz = updates.timezone ?? schedule.timezone ?? 'UTC'
const cronResult = validateCronExpression(updates.cronExpression, tz)
if (!cronResult.isValid) {
return NextResponse.json(
{ error: cronResult.error || 'Invalid cron expression' },
{ status: 400 }
)
}
setFields.cronExpression = updates.cronExpression
if (schedule.status === 'active' && cronResult.nextRun) {
setFields.nextRunAt = cronResult.nextRun
}
}
await db
.update(workflowSchedule)
.set(setFields)
.where(eq(workflowSchedule.id, scheduleId))
logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`)
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: scheduleId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Updated job schedule ${scheduleId}`,
metadata: {},
request,
})
return NextResponse.json({ message: 'Schedule updated successfully' })
}
// reactivate
if (schedule.status === 'active') {
return NextResponse.json({ message: 'Schedule is already active' })

View File

@@ -1 +1 @@
export { CreateScheduleModal } from './create-schedule-modal'
export { ScheduleModal } from './schedule-modal'

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -17,17 +17,22 @@ import {
Textarea,
TimePicker,
} from '@/components/emcn'
import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils'
import { useCreateSchedule } from '@/hooks/queries/schedules'
import {
DAY_MAP,
parseCronToHumanReadable,
parseCronToScheduleType,
validateCronExpression,
} from '@/lib/workflows/schedules/utils'
import type { ScheduleType } from '@/lib/workflows/schedules/utils'
import type { WorkspaceScheduleData } from '@/hooks/queries/schedules'
import { useCreateSchedule, useUpdateSchedule } from '@/hooks/queries/schedules'
const logger = createLogger('CreateScheduleModal')
const logger = createLogger('ScheduleModal')
const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone
type SelectOption = { label: string; value: string }
type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'
const SCHEDULE_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Every X Minutes', value: 'minutes' },
{ label: 'Hourly', value: 'hourly' },
@@ -47,16 +52,6 @@ const WEEKDAY_OPTIONS: SelectOption[] = [
{ label: 'Sunday', value: 'SUN' },
]
const DAY_MAP: Record<string, number> = {
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6,
SUN: 0,
}
const TIMEZONE_OPTIONS: SelectOption[] = [
{ label: 'UTC', value: 'UTC' },
{ label: 'US Pacific (UTC-8)', value: 'America/Los_Angeles' },
@@ -95,10 +90,11 @@ const TIMEZONE_OPTIONS: SelectOption[] = [
{ label: 'Auckland (UTC+12)', value: 'Pacific/Auckland' },
]
interface CreateScheduleModalProps {
interface ScheduleModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
schedule?: WorkspaceScheduleData
}
/**
@@ -155,12 +151,16 @@ function buildCronExpression(
}
}
export function CreateScheduleModal({
export function ScheduleModal({
open,
onOpenChange,
workspaceId,
}: CreateScheduleModalProps) {
schedule,
}: ScheduleModalProps) {
const createScheduleMutation = useCreateSchedule()
const updateScheduleMutation = useUpdateSchedule()
const isEditing = Boolean(schedule)
const [title, setTitle] = useState('')
const [prompt, setPrompt] = useState('')
@@ -177,7 +177,27 @@ export function CreateScheduleModal({
const [startDate, setStartDate] = useState('')
const [lifecycle, setLifecycle] = useState<'persistent' | 'until_complete'>('persistent')
const [maxRuns, setMaxRuns] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
if (!open || !schedule) return
const cronState = parseCronToScheduleType(schedule.cronExpression)
setTitle(schedule.jobTitle || '')
setPrompt(schedule.prompt || '')
setScheduleType(cronState.scheduleType)
setMinutesInterval(cronState.minutesInterval)
setHourlyMinute(cronState.hourlyMinute)
setDailyTime(cronState.dailyTime)
setWeeklyDay(cronState.weeklyDay)
setWeeklyDayTime(cronState.weeklyDayTime)
setMonthlyDay(cronState.monthlyDay)
setMonthlyTime(cronState.monthlyTime)
setCronExpression(cronState.cronExpression)
setTimezone(schedule.timezone || DEFAULT_TIMEZONE)
setLifecycle(schedule.lifecycle === 'until_complete' ? 'until_complete' : 'persistent')
setMaxRuns(schedule.maxRuns ? String(schedule.maxRuns) : '')
setStartDate('')
}, [open, schedule])
const computedCron = useMemo(
() =>
@@ -245,7 +265,7 @@ export function CreateScheduleModal({
setStartDate('')
setLifecycle('persistent')
setMaxRuns('')
setCreateError(null)
setSubmitError(null)
}
const handleClose = () => {
@@ -253,26 +273,39 @@ export function CreateScheduleModal({
resetForm()
}
const handleCreate = async () => {
const handleSubmit = async () => {
if (!computedCron || !isFormValid) return
setCreateError(null)
setSubmitError(null)
try {
await createScheduleMutation.mutateAsync({
workspaceId,
title: title.trim(),
prompt: prompt.trim(),
cronExpression: computedCron,
timezone: resolvedTimezone,
lifecycle,
maxRuns: lifecycle === 'until_complete' && maxRuns ? parseInt(maxRuns, 10) : undefined,
startDate: startDate || undefined,
})
if (isEditing && schedule) {
await updateScheduleMutation.mutateAsync({
scheduleId: schedule.id,
workspaceId,
title: title.trim(),
prompt: prompt.trim(),
cronExpression: computedCron,
timezone: resolvedTimezone,
lifecycle,
maxRuns: lifecycle === 'until_complete' && maxRuns ? parseInt(maxRuns, 10) : null,
})
} else {
await createScheduleMutation.mutateAsync({
workspaceId,
title: title.trim(),
prompt: prompt.trim(),
cronExpression: computedCron,
timezone: resolvedTimezone,
lifecycle,
maxRuns: lifecycle === 'until_complete' && maxRuns ? parseInt(maxRuns, 10) : undefined,
startDate: startDate || undefined,
})
}
handleClose()
} catch (error: unknown) {
logger.error('Schedule creation failed:', { error })
setCreateError(
error instanceof Error ? error.message : 'Failed to create schedule. Please try again.'
logger.error('Schedule submission failed:', { error })
setSubmitError(
error instanceof Error ? error.message : 'Failed to save schedule. Please try again.'
)
}
}
@@ -280,7 +313,7 @@ export function CreateScheduleModal({
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='lg'>
<ModalHeader>Create new schedule</ModalHeader>
<ModalHeader>{isEditing ? 'Edit schedule' : 'Create new schedule'}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[18px]'>
{/* Title */}
@@ -290,7 +323,7 @@ export function CreateScheduleModal({
value={title}
onChange={(e) => {
setTitle(e.target.value)
if (createError) setCreateError(null)
if (submitError) setSubmitError(null)
}}
placeholder='e.g., Daily report generation'
className='h-9'
@@ -308,7 +341,7 @@ export function CreateScheduleModal({
value={prompt}
onChange={(e) => {
setPrompt(e.target.value)
if (createError) setCreateError(null)
if (submitError) setSubmitError(null)
}}
placeholder='Describe what this scheduled task should do...'
className='min-h-[80px] resize-none'
@@ -442,18 +475,19 @@ export function CreateScheduleModal({
</div>
)}
{/* Start Date */}
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-secondary)]'>
Start date
<span className='ml-[4px] font-normal text-[var(--text-muted)]'>(optional)</span>
</p>
<DatePicker
value={startDate}
onChange={setStartDate}
placeholder='Starts immediately'
/>
</div>
{!isEditing && (
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-secondary)]'>
Start date
<span className='ml-[4px] font-normal text-[var(--text-muted)]'>(optional)</span>
</p>
<DatePicker
value={startDate}
onChange={setStartDate}
placeholder='Starts immediately'
/>
</div>
)}
{/* Lifecycle */}
<div className='flex flex-col gap-[8px]'>
@@ -510,8 +544,8 @@ export function CreateScheduleModal({
)}
{/* Error */}
{createError && (
<p className='text-[13px] text-[var(--text-error)] leading-tight'>{createError}</p>
{submitError && (
<p className='text-[13px] text-[var(--text-error)] leading-tight'>{submitError}</p>
)}
</div>
</ModalBody>
@@ -522,10 +556,12 @@ export function CreateScheduleModal({
</Button>
<Button
variant='tertiary'
onClick={handleCreate}
disabled={!isFormValid || createScheduleMutation.isPending}
onClick={handleSubmit}
disabled={!isFormValid || createScheduleMutation.isPending || updateScheduleMutation.isPending}
>
{createScheduleMutation.isPending ? 'Creating...' : 'Create'}
{isEditing
? updateScheduleMutation.isPending ? 'Saving...' : 'Save changes'
: createScheduleMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -0,0 +1 @@
export { ScheduleContextMenu } from './schedule-context-menu'

View File

@@ -0,0 +1,98 @@
'use client'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface ScheduleContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
isJob: boolean
isActive: boolean
onEdit?: () => void
onPause?: () => void
onResume?: () => void
onDelete?: () => void
}
export function ScheduleContextMenu({
isOpen,
position,
menuRef,
onClose,
isJob,
isActive,
onEdit,
onPause,
onResume,
onDelete,
}: ScheduleContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{isJob && onEdit && (
<PopoverItem
onClick={() => {
onEdit()
onClose()
}}
>
Edit
</PopoverItem>
)}
{isJob && onEdit && <PopoverDivider />}
{isActive && onPause && (
<PopoverItem
onClick={() => {
onPause()
onClose()
}}
>
Pause
</PopoverItem>
)}
{!isActive && onResume && (
<PopoverItem
onClick={() => {
onResume()
onClose()
}}
>
Resume
</PopoverItem>
)}
{(onPause || onResume) && onDelete && <PopoverDivider />}
{onDelete && (
<PopoverItem
onClick={() => {
onDelete()
onClose()
}}
>
Delete
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -3,14 +3,22 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Calendar } from '@/components/emcn/icons'
import { formatAbsoluteDate } from '@/lib/core/utils/formatting'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { CreateScheduleModal } from '@/app/workspace/[workspaceId]/schedules/components/create-schedule-modal'
import { ScheduleModal } from '@/app/workspace/[workspaceId]/schedules/components/create-schedule-modal'
import { ScheduleContextMenu } from '@/app/workspace/[workspaceId]/schedules/components/schedule-context-menu'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import type { WorkspaceScheduleData } from '@/hooks/queries/schedules'
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
import {
useDeleteSchedule,
useDisableSchedule,
useReactivateSchedule,
useWorkspaceSchedules,
} from '@/hooks/queries/schedules'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Schedules')
@@ -36,12 +44,26 @@ export function Schedules() {
const workspaceId = params.workspaceId as string
const { data: allItems = [], isLoading, error } = useWorkspaceSchedules(workspaceId)
const deleteSchedule = useDeleteSchedule()
const disableSchedule = useDisableSchedule()
const reactivateSchedule = useReactivateSchedule()
if (error) {
logger.error('Failed to load schedules:', error)
}
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
menuRef: rowMenuRef,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [activeSchedule, setActiveSchedule] = useState<WorkspaceScheduleData | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
@@ -90,16 +112,71 @@ export function Schedules() {
[filteredItems]
)
const itemById = useMemo(
() => new Map(filteredItems.map((i) => [i.id, i])),
[filteredItems]
)
const handleRowClick = useCallback(
(rowId: string) => {
const item = filteredItems.find((i) => i.id === rowId)
if (isRowContextMenuOpen) return
const item = itemById.get(rowId)
if (item?.workflowId) {
router.push(`/workspace/${workspaceId}/w/${item.workflowId}`)
}
},
[filteredItems, router, workspaceId]
[itemById, isRowContextMenuOpen, router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const item = itemById.get(rowId) ?? null
setActiveSchedule(item)
handleRowCtxMenu(e)
},
[itemById, handleRowCtxMenu]
)
const handleDelete = async () => {
if (!activeSchedule) return
try {
await deleteSchedule.mutateAsync({
scheduleId: activeSchedule.id,
workspaceId,
})
setIsDeleteDialogOpen(false)
setActiveSchedule(null)
} catch (err) {
logger.error('Failed to delete schedule:', err)
}
}
const handlePause = async () => {
if (!activeSchedule) return
try {
await disableSchedule.mutateAsync({
scheduleId: activeSchedule.id,
workspaceId,
})
} catch (err) {
logger.error('Failed to pause schedule:', err)
}
}
const handleResume = async () => {
if (!activeSchedule) return
try {
await reactivateSchedule.mutateAsync({
scheduleId: activeSchedule.id,
workflowId: activeSchedule.workflowId || '',
blockId: '',
workspaceId,
})
} catch (err) {
logger.error('Failed to resume schedule:', err)
}
}
return (
<>
<Resource
@@ -120,13 +197,69 @@ export function Schedules() {
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
/>
<CreateScheduleModal
<ScheduleContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
menuRef={rowMenuRef}
onClose={closeRowContextMenu}
isJob={activeSchedule?.sourceType === 'job'}
isActive={activeSchedule?.status === 'active'}
onEdit={() => setIsEditModalOpen(true)}
onPause={handlePause}
onResume={handleResume}
onDelete={() => setIsDeleteDialogOpen(true)}
/>
<ScheduleModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
workspaceId={workspaceId}
/>
<ScheduleModal
open={isEditModalOpen}
onOpenChange={(open) => {
setIsEditModalOpen(open)
if (!open) setActiveSchedule(null)
}}
workspaceId={workspaceId}
schedule={activeSchedule ?? undefined}
/>
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Schedule</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{activeSchedule?.jobTitle || activeSchedule?.workflowName || 'this schedule'}
</span>
?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setIsDeleteDialogOpen(false)
setActiveSchedule(null)
}}
disabled={deleteSchedule.isPending}
>
Cancel
</Button>
<Button variant='default' onClick={handleDelete} disabled={deleteSchedule.isPending}>
{deleteSchedule.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -275,6 +275,49 @@ export function useDeleteSchedule() {
})
}
/**
* Mutation to update fields on a standalone job schedule
*/
export function useUpdateSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
scheduleId,
workspaceId,
...updates
}: {
scheduleId: string
workspaceId: string
title?: string
prompt?: string
cronExpression?: string
timezone?: string
lifecycle?: 'persistent' | 'until_complete'
maxRuns?: number | null
}) => {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update', ...updates }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to update schedule')
}
return { workspaceId }
},
onSuccess: ({ workspaceId }) => {
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
},
onError: (error) => {
logger.error('Failed to update schedule', { error })
},
})
}
/**
* Mutation to create a standalone scheduled job
*/

View File

@@ -484,6 +484,106 @@ export const parseCronToHumanReadable = (cronExpression: string, timezone?: stri
}
}
const REVERSE_DAY_MAP: Record<number, string> = {
0: 'SUN',
1: 'MON',
2: 'TUE',
3: 'WED',
4: 'THU',
5: 'FRI',
6: 'SAT',
7: 'SUN',
}
export type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'
export interface CronFormState {
scheduleType: ScheduleType
minutesInterval: string
hourlyMinute: string
dailyTime: string
weeklyDay: string
weeklyDayTime: string
monthlyDay: string
monthlyTime: string
cronExpression: string
}
const CRON_FORM_DEFAULTS: CronFormState = {
scheduleType: 'custom',
minutesInterval: '15',
hourlyMinute: '0',
dailyTime: '09:00',
weeklyDay: 'MON',
weeklyDayTime: '09:00',
monthlyDay: '1',
monthlyTime: '09:00',
cronExpression: '',
}
/**
* Reverse-parses a cron expression into schedule type and form field values.
* Used to pre-populate the schedule modal when editing an existing schedule.
*/
export function parseCronToScheduleType(cronExpression: string | null | undefined): CronFormState {
if (!cronExpression?.trim()) {
return { ...CRON_FORM_DEFAULTS }
}
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5) {
return { ...CRON_FORM_DEFAULTS, cronExpression }
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
const pad = (n: number) => String(n).padStart(2, '0')
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const interval = parseInt(minute.slice(2), 10)
if (!isNaN(interval) && interval > 0) {
return { ...CRON_FORM_DEFAULTS, scheduleType: 'minutes', minutesInterval: String(interval) }
}
}
const m = parseInt(minute, 10)
const h = parseInt(hour, 10)
if (!isNaN(m) && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return { ...CRON_FORM_DEFAULTS, scheduleType: 'hourly', hourlyMinute: String(m) }
}
if (!isNaN(m) && !isNaN(h) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return { ...CRON_FORM_DEFAULTS, scheduleType: 'daily', dailyTime: `${pad(h)}:${pad(m)}` }
}
if (!isNaN(m) && !isNaN(h) && dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const dow = parseInt(dayOfWeek, 10)
const dayName = REVERSE_DAY_MAP[dow]
if (dayName) {
return {
...CRON_FORM_DEFAULTS,
scheduleType: 'weekly',
weeklyDay: dayName,
weeklyDayTime: `${pad(h)}:${pad(m)}`,
}
}
}
if (!isNaN(m) && !isNaN(h) && dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
const dom = parseInt(dayOfMonth, 10)
if (!isNaN(dom) && dom >= 1 && dom <= 31) {
return {
...CRON_FORM_DEFAULTS,
scheduleType: 'monthly',
monthlyDay: String(dom),
monthlyTime: `${pad(h)}:${pad(m)}`,
}
}
}
return { ...CRON_FORM_DEFAULTS, cronExpression }
}
/**
* Format schedule information for display
*/