feat(schedules): move schedule configuration out of modals into subblocks (#1805)

* feat(schedules): move schedule configuration out of modals into subblocks

* added more timezones

* added simple in-memory rate limiting to update schedule, validation on numeric values for date and time, fix update schedule behavior

* fix failing tests, ack PR comments

* surface better errors
This commit is contained in:
Waleed
2025-11-04 13:36:50 -08:00
committed by GitHub
parent e9ff94f06f
commit a29f9fd55f
15 changed files with 953 additions and 1328 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| Trigger | Start condition |
|---------|-----------------|
| **Start** | Editor runs, deploy-to-API requests, or chat messages |
| **Schedule** | Timer managed in schedule modal |
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
+14 -113
View File
@@ -14,10 +14,8 @@ describe('Schedule Configuration API Route', () => {
beforeEach(() => {
vi.resetModules()
// Mock all dependencies
mockExecutionDependencies()
// Mock auth
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: {
@@ -27,12 +25,10 @@ describe('Schedule Configuration API Route', () => {
}),
}))
// Mock permissions
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), // User has admin permissions
}))
// Extend sampleWorkflowState for scheduling
const _workflowStateWithSchedule = {
...sampleWorkflowState,
blocks: {
@@ -50,10 +46,14 @@ describe('Schedule Configuration API Route', () => {
},
}
// Create mock database with test schedules
// Mock the database to return workflow data for authorization check
vi.doMock('@sim/db', () => {
let callCount = 0
const mockInsert = {
values: vi.fn().mockImplementation(() => ({
onConflictDoUpdate: vi.fn().mockResolvedValue({}),
})),
}
const mockDb = {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
@@ -85,11 +85,7 @@ describe('Schedule Configuration API Route', () => {
})),
})),
})),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockImplementation(() => ({
onConflictDoUpdate: vi.fn().mockResolvedValue({}),
})),
})),
insert: vi.fn().mockReturnValue(mockInsert),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
@@ -98,12 +94,17 @@ describe('Schedule Configuration API Route', () => {
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
transaction: vi.fn().mockImplementation(async (callback) => {
const tx = {
insert: vi.fn().mockReturnValue(mockInsert),
}
return callback(tx)
}),
}
return { db: mockDb }
})
// Fix imports for route.ts
vi.doMock('crypto', () => ({
randomUUID: vi.fn(() => 'test-uuid'),
default: {
@@ -111,7 +112,6 @@ describe('Schedule Configuration API Route', () => {
},
}))
// Mock the schedule utils
vi.doMock('@/lib/schedules/utils', () => ({
getScheduleTimeValues: vi.fn().mockReturnValue({
scheduleTime: '09:30',
@@ -134,6 +134,7 @@ describe('Schedule Configuration API Route', () => {
}),
generateCronExpression: vi.fn().mockReturnValue('0 9 * * *'),
calculateNextRunTime: vi.fn().mockReturnValue(new Date()),
validateCronExpression: vi.fn().mockReturnValue({ isValid: true }),
BlockState: {},
}))
})
@@ -146,7 +147,6 @@ describe('Schedule Configuration API Route', () => {
* Test creating a new schedule
*/
it('should create a new schedule successfully', async () => {
// Create a mock request with schedule data
const req = createMockRequest('POST', {
workflowId: 'workflow-id',
state: {
@@ -166,17 +166,13 @@ describe('Schedule Configuration API Route', () => {
},
})
// Import the route handler after mocks are set up
const { POST } = await import('@/app/api/schedules/route')
// Call the handler
const response = await POST(req)
// Verify response
expect(response).toBeDefined()
expect(response.status).toBe(200)
// Validate response data
const responseData = await response.json()
expect(responseData).toHaveProperty('message', 'Schedule updated')
expect(responseData).toHaveProperty('cronExpression', '0 9 * * *')
@@ -187,90 +183,10 @@ describe('Schedule Configuration API Route', () => {
// Instead, we just verify that the response has the expected properties
})
/**
* Test removing a schedule
*/
it('should remove a schedule when startWorkflow is not schedule', async () => {
// Skip this test for now, as we're having issues with the mock
// This would require deeper debugging of how the mock is being applied
expect(true).toBe(true)
/*
// Mock the db to verify delete is called
const dbDeleteMock = vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
}))
vi.doMock('@sim/db', () => ({
db: {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => []),
})),
})),
})),
delete: dbDeleteMock,
},
}))
// Override the getSubBlockValue to return 'manual'
vi.doMock('@/lib/schedules/utils', () => ({
getScheduleTimeValues: vi.fn(),
getSubBlockValue: vi.fn().mockImplementation((block: any, id: string) => {
const subBlocks = {
startWorkflow: 'manual', // Changed to manual
scheduleType: 'daily',
}
return subBlocks[id] || ''
}),
generateCronExpression: vi.fn(),
calculateNextRunTime: vi.fn(),
BlockState: {},
}))
*/
// Since we're skipping this test, we don't need the rest of the implementation
/*
// Create a mock request
const req = createMockRequest('POST', {
workflowId: 'workflow-id',
state: {
blocks: {
'starter-id': {
type: 'starter',
subBlocks: {
startWorkflow: { value: 'manual' }, // Manual trigger
scheduleType: { value: 'daily' },
}
}
},
edges: [],
loops: {}
},
})
// Import the route handler after mocks are set up
const { POST } = await import('@/app/api/schedules/route')
// Call the handler
const response = await POST(req)
// Verify delete was called
expect(dbDeleteMock).toHaveBeenCalled()
// Check response
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('message', 'Schedule removed')
*/
})
/**
* Test error handling
*/
it('should handle errors gracefully', async () => {
// Mock the db to throw an error on insert
vi.doMock('@sim/db', () => ({
db: {
select: vi.fn().mockImplementation(() => ({
@@ -286,19 +202,15 @@ describe('Schedule Configuration API Route', () => {
},
}))
// Create a mock request
const req = createMockRequest('POST', {
workflowId: 'workflow-id',
state: { blocks: {}, edges: [], loops: {} },
})
// Import the route handler after mocks are set up
const { POST } = await import('@/app/api/schedules/route')
// Call the handler
const response = await POST(req)
// Check response is an error (could be 400 or 500 depending on error handling)
expect(response.status).toBeGreaterThanOrEqual(400)
const data = await response.json()
expect(data).toHaveProperty('error')
@@ -308,24 +220,19 @@ describe('Schedule Configuration API Route', () => {
* Test authentication requirement
*/
it('should require authentication', async () => {
// Mock auth to return no session
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
// Create a mock request
const req = createMockRequest('POST', {
workflowId: 'workflow-id',
state: { blocks: {}, edges: [], loops: {} },
})
// Import the route handler after mocks are set up
const { POST } = await import('@/app/api/schedules/route')
// Call the handler
const response = await POST(req)
// Check response requires auth
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error', 'Unauthorized')
@@ -335,20 +242,14 @@ describe('Schedule Configuration API Route', () => {
* Test invalid data handling
*/
it('should validate input data', async () => {
// Create a mock request with invalid data
const req = createMockRequest('POST', {
// Missing required fields
workflowId: 'workflow-id',
// Missing state
})
// Import the route handler after mocks are set up
const { POST } = await import('@/app/api/schedules/route')
// Call the handler
const response = await POST(req)
// Check response validates data
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Invalid request data')
+47 -44
View File
@@ -28,10 +28,6 @@ const ScheduleRequestSchema = z.object({
}),
})
// Track recent requests to reduce redundant logging
const recentRequests = new Map<string, number>()
const LOGGING_THROTTLE_MS = 5000 // 5 seconds between logging for the same workflow
function hasValidScheduleConfig(
scheduleType: string | undefined,
scheduleValues: ReturnType<typeof getScheduleTimeValues>,
@@ -86,7 +82,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 })
}
// Check if user has permission to view this workflow
const [workflowRecord] = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
@@ -97,10 +92,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// Check authorization - either the user owns the workflow or has workspace permissions
let isAuthorized = workflowRecord.userId === session.user.id
// If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions
if (!isAuthorized && workflowRecord.workspaceId) {
const userPermission = await getUserEntityPermissions(
session.user.id,
@@ -114,16 +107,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Not authorized to view this workflow' }, { status: 403 })
}
const now = Date.now()
const lastLog = recentRequests.get(workflowId) || 0
const shouldLog = now - lastLog > LOGGING_THROTTLE_MS
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
if (shouldLog) {
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
recentRequests.set(workflowId, now)
}
// Build query conditions
const conditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
conditions.push(eq(workflowSchedule.blockId, blockId))
@@ -136,7 +121,7 @@ export async function GET(req: NextRequest) {
.limit(1)
const headers = new Headers()
headers.set('Cache-Control', 'max-age=30') // Cache for 30 seconds
headers.set('Cache-Control', 'max-age=30')
if (schedule.length === 0) {
return NextResponse.json({ schedule: null }, { headers })
@@ -161,6 +146,10 @@ export async function GET(req: NextRequest) {
}
}
const saveAttempts = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT_WINDOW = 60000 // 1 minute
const RATE_LIMIT_MAX = 10 // 10 saves per minute
/**
* Create or update a schedule for a workflow
*/
@@ -174,12 +163,28 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const now = Date.now()
const userKey = session.user.id
const limit = saveAttempts.get(userKey)
if (limit && limit.resetAt > now) {
if (limit.count >= RATE_LIMIT_MAX) {
logger.warn(`[${requestId}] Rate limit exceeded for user: ${userKey}`)
return NextResponse.json(
{ error: 'Too many save attempts. Please wait a moment and try again.' },
{ status: 429 }
)
}
limit.count++
} else {
saveAttempts.set(userKey, { count: 1, resetAt: now + RATE_LIMIT_WINDOW })
}
const body = await req.json()
const { workflowId, blockId, state } = ScheduleRequestSchema.parse(body)
logger.info(`[${requestId}] Processing schedule update for workflow ${workflowId}`)
// Check if user has permission to modify this workflow
const [workflowRecord] = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
@@ -191,10 +196,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// Check authorization - either the user owns the workflow or has write/admin workspace permissions
let isAuthorized = workflowRecord.userId === session.user.id
// If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions
if (!isAuthorized && workflowRecord.workspaceId) {
const userPermission = await getUserEntityPermissions(
session.user.id,
@@ -211,15 +214,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Not authorized to modify this workflow' }, { status: 403 })
}
// Find the target block - prioritize the specific blockId if provided
let targetBlock: BlockState | undefined
if (blockId) {
// If blockId is provided, find that specific block
targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as
| BlockState
| undefined
} else {
// Fallback: find either starter block or schedule trigger block
targetBlock = Object.values(state.blocks).find(
(block: any) => block.type === 'starter' || block.type === 'schedule'
) as BlockState | undefined
@@ -240,12 +240,9 @@ export async function POST(req: NextRequest) {
const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock)
// For schedule trigger blocks, we always have valid configuration
// For starter blocks, check if schedule is selected and has valid config
const isScheduleBlock = targetBlock.type === 'schedule'
const hasValidConfig = isScheduleBlock || (startWorkflow === 'schedule' && hasScheduleConfig)
// Debug logging to understand why validation fails
logger.info(`[${requestId}] Schedule validation debug:`, {
workflowId,
blockId,
@@ -266,7 +263,6 @@ export async function POST(req: NextRequest) {
logger.info(
`[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found`
)
// Build delete conditions
const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
deleteConditions.push(eq(workflowSchedule.blockId, blockId))
@@ -307,14 +303,16 @@ export async function POST(req: NextRequest) {
cronExpression = generateCronExpression(defaultScheduleType, scheduleValues)
// Additional validation for custom cron expressions
if (defaultScheduleType === 'custom' && cronExpression) {
// Validate with timezone for accurate validation
// Always validate the generated cron expression
if (cronExpression) {
const validation = validateCronExpression(cronExpression, timezone)
if (!validation.isValid) {
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`, {
scheduleType: defaultScheduleType,
cronExpression,
})
return NextResponse.json(
{ error: `Invalid cron expression: ${validation.error}` },
{ error: `Invalid schedule configuration: ${validation.error}` },
{ status: 400 }
)
}
@@ -325,9 +323,10 @@ export async function POST(req: NextRequest) {
logger.debug(
`[${requestId}] Generated cron: ${cronExpression}, next run at: ${nextRunAt.toISOString()}`
)
} catch (error) {
} catch (error: any) {
logger.error(`[${requestId}] Error generating schedule: ${error}`)
return NextResponse.json({ error: 'Failed to generate schedule' }, { status: 400 })
const errorMessage = error?.message || 'Failed to generate schedule'
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
const values = {
@@ -354,20 +353,21 @@ export async function POST(req: NextRequest) {
failedCount: 0, // Reset failure count on reconfiguration
}
await db
.insert(workflowSchedule)
.values(values)
.onConflictDoUpdate({
target: [workflowSchedule.workflowId, workflowSchedule.blockId],
set: setValues,
})
await db.transaction(async (tx) => {
await tx
.insert(workflowSchedule)
.values(values)
.onConflictDoUpdate({
target: [workflowSchedule.workflowId, workflowSchedule.blockId],
set: setValues,
})
})
logger.info(`[${requestId}] Schedule updated for workflow ${workflowId}`, {
nextRunAt: nextRunAt?.toISOString(),
cronExpression,
})
// Track schedule creation/update
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
trackPlatformEvent('platform.schedule.created', {
@@ -382,10 +382,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
message: 'Schedule updated',
schedule: { id: values.id },
nextRunAt,
cronExpression,
})
} catch (error) {
} catch (error: any) {
logger.error(`[${requestId}] Error updating workflow schedule`, error)
if (error instanceof z.ZodError) {
@@ -394,6 +395,8 @@ export async function POST(req: NextRequest) {
{ status: 400 }
)
}
return NextResponse.json({ error: 'Failed to update workflow schedule' }, { status: 500 })
const errorMessage = error?.message || 'Failed to update workflow schedule'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
@@ -19,7 +19,6 @@ export { McpServerSelector } from './mcp-server-modal/mcp-server-selector'
export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector'
export { ProjectSelectorInput } from './project-selector/project-selector-input'
export { ResponseFormat } from './response/response-format'
export { ScheduleConfig } from './schedule/schedule-config'
export { ShortInput } from './short-input'
export { SliderInput } from './slider-input'
export { InputFormat } from './starter/input-format'
@@ -0,0 +1,554 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Check, Save, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useScheduleManagement } from '@/hooks/use-schedule-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('ScheduleSave')
interface ScheduleSaveProps {
blockId: string
isPreview?: boolean
disabled?: boolean
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export function ScheduleSave({ blockId, isPreview = false, disabled = false }: ScheduleSaveProps) {
const params = useParams()
const workflowId = params.workflowId as string
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null)
const [nextRunAt, setNextRunAt] = useState<Date | null>(null)
const [lastRanAt, setLastRanAt] = useState<Date | null>(null)
const [failedCount, setFailedCount] = useState<number>(0)
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
const [savedCronExpression, setSavedCronExpression] = useState<string | null>(null)
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { scheduleId, saveConfig, deleteConfig, isSaving } = useScheduleManagement({
blockId,
isPreview,
})
const scheduleType = useSubBlockStore((state) => state.getValue(blockId, 'scheduleType'))
const scheduleMinutesInterval = useSubBlockStore((state) =>
state.getValue(blockId, 'minutesInterval')
)
const scheduleHourlyMinute = useSubBlockStore((state) => state.getValue(blockId, 'hourlyMinute'))
const scheduleDailyTime = useSubBlockStore((state) => state.getValue(blockId, 'dailyTime'))
const scheduleWeeklyDay = useSubBlockStore((state) => state.getValue(blockId, 'weeklyDay'))
const scheduleWeeklyTime = useSubBlockStore((state) => state.getValue(blockId, 'weeklyDayTime'))
const scheduleMonthlyDay = useSubBlockStore((state) => state.getValue(blockId, 'monthlyDay'))
const scheduleMonthlyTime = useSubBlockStore((state) => state.getValue(blockId, 'monthlyTime'))
const scheduleCronExpression = useSubBlockStore((state) =>
state.getValue(blockId, 'cronExpression')
)
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = []
if (!scheduleType) {
missingFields.push('Frequency')
return { valid: false, missingFields }
}
switch (scheduleType) {
case 'minutes': {
const minutesNum = Number(scheduleMinutesInterval)
if (
!scheduleMinutesInterval ||
Number.isNaN(minutesNum) ||
minutesNum < 1 ||
minutesNum > 1440
) {
missingFields.push('Minutes Interval (must be 1-1440)')
}
break
}
case 'hourly': {
const hourlyNum = Number(scheduleHourlyMinute)
if (
scheduleHourlyMinute === null ||
scheduleHourlyMinute === undefined ||
scheduleHourlyMinute === '' ||
Number.isNaN(hourlyNum) ||
hourlyNum < 0 ||
hourlyNum > 59
) {
missingFields.push('Minute (must be 0-59)')
}
break
}
case 'daily':
if (!scheduleDailyTime) {
missingFields.push('Time')
}
break
case 'weekly':
if (!scheduleWeeklyDay) {
missingFields.push('Day of Week')
}
if (!scheduleWeeklyTime) {
missingFields.push('Time')
}
break
case 'monthly': {
const monthlyNum = Number(scheduleMonthlyDay)
if (!scheduleMonthlyDay || Number.isNaN(monthlyNum) || monthlyNum < 1 || monthlyNum > 31) {
missingFields.push('Day of Month (must be 1-31)')
}
if (!scheduleMonthlyTime) {
missingFields.push('Time')
}
break
}
case 'custom':
if (!scheduleCronExpression) {
missingFields.push('Cron Expression')
}
break
}
if (!scheduleTimezone && scheduleType !== 'minutes' && scheduleType !== 'hourly') {
missingFields.push('Timezone')
}
return {
valid: missingFields.length === 0,
missingFields,
}
}, [
scheduleType,
scheduleMinutesInterval,
scheduleHourlyMinute,
scheduleDailyTime,
scheduleWeeklyDay,
scheduleWeeklyTime,
scheduleMonthlyDay,
scheduleMonthlyTime,
scheduleCronExpression,
scheduleTimezone,
])
const requiredSubBlockIds = useMemo(() => {
return [
'scheduleType',
'minutesInterval',
'hourlyMinute',
'dailyTime',
'weeklyDay',
'weeklyDayTime',
'monthlyDay',
'monthlyTime',
'cronExpression',
'timezone',
]
}, [])
const subscribedSubBlockValues = useSubBlockStore(
useCallback(
(state) => {
const values: Record<string, any> = {}
requiredSubBlockIds.forEach((subBlockId) => {
const value = state.getValue(blockId, subBlockId)
if (value !== null && value !== undefined && value !== '') {
values[subBlockId] = value
}
})
return values
},
[blockId, requiredSubBlockIds]
)
)
const previousValuesRef = useRef<Record<string, any>>({})
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (saveStatus !== 'error') {
previousValuesRef.current = subscribedSubBlockValues
return
}
const hasChanges = Object.keys(subscribedSubBlockValues).some(
(key) =>
previousValuesRef.current[key] !== (subscribedSubBlockValues as Record<string, any>)[key]
)
if (!hasChanges) {
return
}
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
validationTimeoutRef.current = setTimeout(() => {
const validation = validateRequiredFields()
if (validation.valid) {
setErrorMessage(null)
setSaveStatus('idle')
logger.debug('Error cleared after validation passed', { blockId })
} else {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
logger.debug('Error message updated', {
blockId,
missingFields: validation.missingFields,
})
}
previousValuesRef.current = subscribedSubBlockValues
}, 300)
return () => {
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
}
}, [blockId, subscribedSubBlockValues, saveStatus, validateRequiredFields])
const fetchScheduleStatus = useCallback(async () => {
if (!scheduleId || isPreview) return
setIsLoadingStatus(true)
try {
const response = await fetch(
`/api/schedules?workflowId=${workflowId}&blockId=${blockId}&mode=schedule`
)
if (response.ok) {
const data = await response.json()
if (data.schedule) {
setScheduleStatus(data.schedule.status)
setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null)
setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null)
setFailedCount(data.schedule.failedCount || 0)
setSavedCronExpression(data.schedule.cronExpression || null)
}
}
} catch (error) {
logger.error('Error fetching schedule status', { error })
} finally {
setIsLoadingStatus(false)
}
}, [workflowId, blockId, scheduleId, isPreview])
useEffect(() => {
if (scheduleId && !isPreview) {
fetchScheduleStatus()
}
}, [scheduleId, isPreview, fetchScheduleStatus])
const handleSave = async () => {
if (isPreview || disabled) return
setSaveStatus('saving')
setErrorMessage(null)
try {
const validation = validateRequiredFields()
if (!validation.valid) {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
setSaveStatus('error')
return
}
const result = await saveConfig()
if (!result.success) {
throw new Error('Save config returned false')
}
setSaveStatus('saved')
setErrorMessage(null)
const scheduleIdValue = useSubBlockStore.getState().getValue(blockId, 'scheduleId')
collaborativeSetSubblockValue(blockId, 'scheduleId', scheduleIdValue)
if (result.nextRunAt) {
setNextRunAt(new Date(result.nextRunAt))
setScheduleStatus('active')
}
// Fetch additional status info, then apply cron from save result to prevent stale data
await fetchScheduleStatus()
if (result.cronExpression) {
setSavedCronExpression(result.cronExpression)
}
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
logger.info('Schedule configuration saved successfully', {
blockId,
hasScheduleId: !!scheduleId,
})
} catch (error: any) {
setSaveStatus('error')
setErrorMessage(error.message || 'An error occurred while saving.')
logger.error('Error saving schedule config', { error })
}
}
const handleDelete = async () => {
if (isPreview || disabled) return
setShowDeleteDialog(false)
setDeleteStatus('deleting')
try {
const success = await deleteConfig()
if (!success) {
throw new Error('Failed to delete schedule')
}
setScheduleStatus(null)
setNextRunAt(null)
setLastRanAt(null)
setFailedCount(0)
collaborativeSetSubblockValue(blockId, 'scheduleId', null)
logger.info('Schedule deleted successfully', { blockId })
} catch (error: any) {
setErrorMessage(error.message || 'An error occurred while deleting.')
logger.error('Error deleting schedule', { error })
} finally {
setDeleteStatus('idle')
}
}
const handleDeleteConfirm = () => {
handleDelete()
}
const handleToggleStatus = async () => {
if (!scheduleId || isPreview || disabled) return
try {
const action = scheduleStatus === 'active' ? 'disable' : 'reactivate'
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (response.ok) {
await fetchScheduleStatus()
logger.info(`Schedule ${action}d successfully`, { scheduleId })
} else {
throw new Error(`Failed to ${action} schedule`)
}
} catch (error: any) {
setErrorMessage(
error.message ||
`An error occurred while ${scheduleStatus === 'active' ? 'disabling' : 'reactivating'} the schedule.`
)
logger.error('Error toggling schedule status', { error })
}
}
return (
<div className='mt-2'>
<div className='flex gap-2'>
<Button
onClick={handleSave}
disabled={disabled || isPreview || isSaving || saveStatus === 'saving' || isLoadingStatus}
className={cn(
'h-9 flex-1 rounded-[8px] transition-all duration-200',
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
)}
>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'idle' && (
<>
<Save className='mr-2 h-4 w-4' />
{scheduleId ? 'Update Schedule' : 'Save Schedule'}
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle className='mr-2 h-4 w-4' />
Error
</>
)}
</Button>
{scheduleId && (
<Button
onClick={() => setShowDeleteDialog(true)}
disabled={disabled || isPreview || deleteStatus === 'deleting' || isSaving}
variant='outline'
className='h-9 rounded-[8px] px-3 text-destructive hover:bg-destructive/10'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='h-4 w-4' />
)}
</Button>
)}
</div>
{errorMessage && (
<Alert variant='destructive' className='mt-2'>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{scheduleId && (scheduleStatus || isLoadingStatus || nextRunAt) && (
<div className='mt-2 space-y-1'>
{isLoadingStatus ? (
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Loading schedule status...
</div>
) : (
<>
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant='outline'
className={cn(
'flex cursor-pointer items-center gap-1 font-normal text-xs',
scheduleStatus === 'disabled'
? 'border-amber-200 bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-900/20 dark:text-amber-400'
: 'border-green-200 bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-400'
)}
onClick={handleToggleStatus}
>
<div className='relative mr-0.5 flex items-center justify-center'>
<div
className={cn(
'absolute h-3 w-3 rounded-full',
scheduleStatus === 'disabled' ? 'bg-amber-500/20' : 'bg-green-500/20'
)}
/>
<div
className={cn(
'relative h-2 w-2 rounded-full',
scheduleStatus === 'disabled' ? 'bg-amber-500' : 'bg-green-500'
)}
/>
</div>
{scheduleStatus === 'active' ? 'Active' : 'Disabled'}
</Badge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px]'>
{scheduleStatus === 'disabled' ? (
<p className='text-sm'>Click to reactivate this schedule</p>
) : (
<p className='text-sm'>Click to disable this schedule</p>
)}
</TooltipContent>
</Tooltip>
{failedCount > 0 && (
<span className='text-destructive text-sm'>
⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''}
</span>
)}
</div>
{savedCronExpression && (
<p className='text-muted-foreground text-sm'>
{parseCronToHumanReadable(savedCronExpression, scheduleTimezone || 'UTC')}
</p>
)}
{nextRunAt && (
<p className='text-sm'>
<span className='font-medium'>Next run:</span>{' '}
{nextRunAt.toLocaleString('en-US', {
timeZone: scheduleTimezone || 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}{' '}
{scheduleTimezone || 'UTC'}
</p>
)}
{lastRanAt && (
<p className='text-muted-foreground text-sm'>
<span className='font-medium'>Last ran:</span>{' '}
{lastRanAt.toLocaleString('en-US', {
timeZone: scheduleTimezone || 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}{' '}
{scheduleTimezone || 'UTC'}
</p>
)}
</>
)}
</div>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Schedule Configuration</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this schedule configuration? This will stop the
workflow from running automatically. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -1,634 +0,0 @@
import { useEffect, useState } from 'react'
import { Trash2, X } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { TimeInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
import { UnsavedChangesDialog } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('ScheduleModal')
interface ScheduleModalProps {
isOpen: boolean
onClose: () => void
workflowId: string
blockId: string
onSave: () => Promise<boolean>
onDelete?: () => Promise<boolean>
scheduleId?: string | null
}
export function ScheduleModal({
isOpen,
onClose,
workflowId,
blockId,
onSave,
onDelete,
scheduleId,
}: ScheduleModalProps) {
// States for schedule configuration
const [scheduleType, setScheduleType] = useSubBlockValue(blockId, 'scheduleType')
const [minutesInterval, setMinutesInterval] = useSubBlockValue(blockId, 'minutesInterval')
const [hourlyMinute, setHourlyMinute] = useSubBlockValue(blockId, 'hourlyMinute')
const [dailyTime, setDailyTime] = useSubBlockValue(blockId, 'dailyTime')
const [weeklyDay, setWeeklyDay] = useSubBlockValue(blockId, 'weeklyDay')
const [weeklyDayTime, setWeeklyDayTime] = useSubBlockValue(blockId, 'weeklyDayTime')
const [monthlyDay, setMonthlyDay] = useSubBlockValue(blockId, 'monthlyDay')
const [monthlyTime, setMonthlyTime] = useSubBlockValue(blockId, 'monthlyTime')
const [cronExpression, setCronExpression] = useSubBlockValue(blockId, 'cronExpression')
const [timezone, setTimezone] = useSubBlockValue(blockId, 'timezone')
// Get the startWorkflow value at the component level
const [startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
// UI states
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [showUnsavedChangesConfirm, setShowUnsavedChangesConfirm] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Simpler approach - we'll use this to store the initial values when the modal opens
const [initialValues, setInitialValues] = useState<Record<string, any>>({})
// Initialize initial values when the modal opens
useEffect(() => {
if (isOpen) {
// Capture all current values when modal opens
const currentValues = {
scheduleType: scheduleType || 'daily',
minutesInterval: minutesInterval || '',
hourlyMinute: hourlyMinute || '',
dailyTime: dailyTime || '',
weeklyDay: weeklyDay || 'MON',
weeklyDayTime: weeklyDayTime || '',
monthlyDay: monthlyDay || '',
monthlyTime: monthlyTime || '',
timezone: timezone || 'UTC',
cronExpression: cronExpression || '',
}
setInitialValues(currentValues)
setHasChanges(false)
setErrorMessage(null)
}
}, [isOpen])
// Track changes - simplified approach
useEffect(() => {
if (!isOpen) return
const currentValues = {
scheduleType: scheduleType || 'daily',
minutesInterval: minutesInterval || '',
hourlyMinute: hourlyMinute || '',
dailyTime: dailyTime || '',
weeklyDay: weeklyDay || 'MON',
weeklyDayTime: weeklyDayTime || '',
monthlyDay: monthlyDay || '',
monthlyTime: monthlyTime || '',
timezone: timezone || 'UTC',
cronExpression: cronExpression || '',
}
// Simple JSON comparison to detect any changes
const valuesChanged = JSON.stringify(initialValues) !== JSON.stringify(currentValues)
// For new schedules, consider them changed if any value is set based on schedule type
if (!scheduleId) {
let hasRequiredFields = false
switch (currentValues.scheduleType) {
case 'minutes':
hasRequiredFields = !!currentValues.minutesInterval
break
case 'hourly':
hasRequiredFields = currentValues.hourlyMinute !== ''
break
case 'daily':
hasRequiredFields = !!currentValues.dailyTime
break
case 'weekly':
hasRequiredFields = !!currentValues.weeklyDay && !!currentValues.weeklyDayTime
break
case 'monthly':
hasRequiredFields = !!currentValues.monthlyDay && !!currentValues.monthlyTime
break
case 'custom':
hasRequiredFields = !!currentValues.cronExpression
break
}
setHasChanges(valuesChanged || hasRequiredFields)
} else {
setHasChanges(valuesChanged)
}
}, [
isOpen,
scheduleId,
scheduleType,
minutesInterval,
hourlyMinute,
dailyTime,
weeklyDay,
weeklyDayTime,
monthlyDay,
monthlyTime,
timezone,
cronExpression,
initialValues,
])
// Handle modal close
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChangesConfirm(true)
} else {
onClose()
}
}
// Handle confirming close despite unsaved changes
const handleConfirmClose = () => {
// Revert form values to initial values
if (hasChanges) {
setScheduleType(initialValues.scheduleType)
setMinutesInterval(initialValues.minutesInterval)
setHourlyMinute(initialValues.hourlyMinute)
setDailyTime(initialValues.dailyTime)
setWeeklyDay(initialValues.weeklyDay)
setWeeklyDayTime(initialValues.weeklyDayTime)
setMonthlyDay(initialValues.monthlyDay)
setMonthlyTime(initialValues.monthlyTime)
setTimezone(initialValues.timezone)
setCronExpression(initialValues.cronExpression)
}
setShowUnsavedChangesConfirm(false)
onClose()
}
// Handle canceling the close
const handleCancelClose = () => {
setShowUnsavedChangesConfirm(false)
}
// Handle saving the schedule
const handleSave = async () => {
setErrorMessage(null)
setIsSaving(true)
try {
// Validate inputs based on schedule type
if (scheduleType === 'minutes' && !minutesInterval) {
setErrorMessage('Please enter minutes interval')
setIsSaving(false)
return
}
if (scheduleType === 'hourly' && hourlyMinute === '') {
setErrorMessage('Please enter minute of the hour')
setIsSaving(false)
return
}
if (scheduleType === 'daily' && !dailyTime) {
setErrorMessage('Please enter time of day')
setIsSaving(false)
return
}
if (scheduleType === 'weekly' && !weeklyDayTime) {
setErrorMessage('Please enter time of day')
setIsSaving(false)
return
}
if (scheduleType === 'monthly' && (!monthlyDay || !monthlyTime)) {
setErrorMessage('Please enter day of month and time')
setIsSaving(false)
return
}
if (scheduleType === 'custom' && !cronExpression) {
setErrorMessage('Please enter a cron expression')
setIsSaving(false)
return
}
// Make sure the block's startWorkflow field is set to 'schedule'
logger.debug('Current startWorkflow value:', startWorkflow)
// Important: Set startWorkflow to 'schedule' in two ways for maximum reliability
// 1. Via the hook which will trigger a state update
if (startWorkflow !== 'schedule') {
logger.debug('Setting startWorkflow to schedule via hook')
setStartWorkflow('schedule')
}
// 2. Also directly set the value in the subblock store for immediate effect
// This provides a more reliable way to ensure the value is set
logger.debug('Setting startWorkflow to schedule directly in store')
useSubBlockStore.getState().setValue(blockId, 'startWorkflow', 'schedule')
// Give time for the state updates to propagate
await new Promise((resolve) => setTimeout(resolve, 150))
// Call the onSave function passed from the parent component
// This will handle the actual API call and store update
const success = await onSave()
if (success) {
// Update initial values to match current state
const updatedValues = {
scheduleType: scheduleType || 'daily',
minutesInterval: minutesInterval || '',
hourlyMinute: hourlyMinute || '',
dailyTime: dailyTime || '',
weeklyDay: weeklyDay || 'MON',
weeklyDayTime: weeklyDayTime || '',
monthlyDay: monthlyDay || '',
monthlyTime: monthlyTime || '',
timezone: timezone || 'UTC',
cronExpression: cronExpression || '',
}
logger.debug('Schedule saved successfully, updating initial values', updatedValues)
setInitialValues(updatedValues)
setHasChanges(false)
onClose()
}
} catch (error) {
logger.error('Error saving schedule:', { error })
setErrorMessage('Failed to save schedule')
} finally {
setIsSaving(false)
}
}
// Handle deleting the schedule
const handleDelete = async () => {
if (!onDelete) return
setIsDeleting(true)
try {
const success = await onDelete()
if (success) {
setShowDeleteConfirm(false)
onClose()
}
} catch (error) {
logger.error('Error deleting schedule:', { error })
setErrorMessage('Failed to delete schedule')
} finally {
setIsDeleting(false)
}
}
// Open delete confirmation dialog
const openDeleteConfirm = () => {
setShowDeleteConfirm(true)
}
return (
<>
<DialogContent
className='flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Schedule Configuration</DialogTitle>
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<div className='flex-1 overflow-y-auto px-6 py-6'>
{errorMessage && (
<Alert variant='destructive' className='mb-4'>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<div className='space-y-6'>
{/* Frequency selector */}
<div className='space-y-1'>
<label htmlFor='scheduleType' className='font-medium text-sm'>
Frequency
</label>
<Select
value={scheduleType || 'daily'}
onValueChange={(value) => setScheduleType(value)}
>
<SelectTrigger className='h-10'>
<SelectValue placeholder='Select frequency' />
</SelectTrigger>
<SelectContent>
<SelectItem value='minutes'>Every X Minutes</SelectItem>
<SelectItem value='hourly'>Hourly</SelectItem>
<SelectItem value='daily'>Daily</SelectItem>
<SelectItem value='weekly'>Weekly</SelectItem>
<SelectItem value='monthly'>Monthly</SelectItem>
<SelectItem value='custom'>Custom Cron</SelectItem>
</SelectContent>
</Select>
</div>
{/* Minutes schedule options */}
{scheduleType === 'minutes' && (
<div className='space-y-1'>
<label htmlFor='minutesInterval' className='font-medium text-sm'>
Run Every (minutes)
</label>
<Input
id='minutesInterval'
value={minutesInterval || ''}
onChange={(e) => setMinutesInterval(e.target.value)}
placeholder='15'
type='number'
min='1'
max='59'
className='h-10'
autoComplete='off'
data-form-type='other'
name='minutes-interval'
/>
<p className='text-muted-foreground text-xs'>
Specify the interval in minutes (1-59)
</p>
</div>
)}
{/* Hourly schedule options */}
{scheduleType === 'hourly' && (
<div className='space-y-1'>
<label htmlFor='hourlyMinute' className='font-medium text-sm'>
Minute of the Hour
</label>
<Input
id='hourlyMinute'
value={hourlyMinute || ''}
onChange={(e) => setHourlyMinute(e.target.value)}
placeholder='0'
type='number'
min='0'
max='59'
className='h-10'
autoComplete='off'
data-form-type='other'
name='hourly-minute'
/>
<p className='text-muted-foreground text-xs'>
Specify which minute of each hour the workflow should run (0-59)
</p>
</div>
)}
{/* Daily schedule options */}
{(scheduleType === 'daily' || !scheduleType) && (
<div className='space-y-1'>
<label htmlFor='dailyTime' className='font-medium text-sm'>
Time of Day
</label>
<TimeInput
blockId={blockId}
subBlockId='dailyTime'
placeholder='Select time'
className='h-10'
/>
</div>
)}
{/* Weekly schedule options */}
{scheduleType === 'weekly' && (
<div className='space-y-4'>
<div className='space-y-1'>
<label htmlFor='weeklyDay' className='font-medium text-sm'>
Day of Week
</label>
<Select value={weeklyDay || 'MON'} onValueChange={(value) => setWeeklyDay(value)}>
<SelectTrigger className='h-10'>
<SelectValue placeholder='Select day' />
</SelectTrigger>
<SelectContent>
<SelectItem value='MON'>Monday</SelectItem>
<SelectItem value='TUE'>Tuesday</SelectItem>
<SelectItem value='WED'>Wednesday</SelectItem>
<SelectItem value='THU'>Thursday</SelectItem>
<SelectItem value='FRI'>Friday</SelectItem>
<SelectItem value='SAT'>Saturday</SelectItem>
<SelectItem value='SUN'>Sunday</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<label htmlFor='weeklyDayTime' className='font-medium text-sm'>
Time of Day
</label>
<TimeInput
blockId={blockId}
subBlockId='weeklyDayTime'
placeholder='Select time'
className='h-10'
/>
</div>
</div>
)}
{/* Monthly schedule options */}
{scheduleType === 'monthly' && (
<div className='space-y-4'>
<div className='space-y-1'>
<label htmlFor='monthlyDay' className='font-medium text-sm'>
Day of Month
</label>
<Input
id='monthlyDay'
value={monthlyDay || ''}
onChange={(e) => setMonthlyDay(e.target.value)}
placeholder='1'
type='number'
min='1'
max='31'
className='h-10'
autoComplete='off'
data-form-type='other'
name='monthly-day'
/>
<p className='text-muted-foreground text-xs'>
Specify which day of the month the workflow should run (1-31)
</p>
</div>
<div className='space-y-1'>
<label htmlFor='monthlyTime' className='font-medium text-sm'>
Time of Day
</label>
<TimeInput
blockId={blockId}
subBlockId='monthlyTime'
placeholder='Select time'
className='h-10'
/>
</div>
</div>
)}
{/* Custom cron options */}
{scheduleType === 'custom' && (
<div className='space-y-1'>
<label htmlFor='cronExpression' className='font-medium text-sm'>
Cron Expression
</label>
<Input
id='cronExpression'
value={cronExpression || ''}
onChange={(e) => setCronExpression(e.target.value)}
placeholder='*/15 * * * *'
className='h-10'
/>
<p className='mt-1 text-muted-foreground text-xs'>
Use standard cron format (e.g., "*/15 * * * *" for every 15 minutes)
</p>
</div>
)}
{/* Timezone configuration - only show for time-specific schedules */}
{scheduleType !== 'minutes' && scheduleType !== 'hourly' && (
<div className='space-y-1'>
<label htmlFor='timezone' className='font-medium text-sm'>
Timezone
</label>
<Select value={timezone || 'UTC'} onValueChange={(value) => setTimezone(value)}>
<SelectTrigger className='h-10'>
<SelectValue placeholder='Select timezone' />
</SelectTrigger>
<SelectContent>
<SelectItem value='UTC'>UTC</SelectItem>
<SelectItem value='America/New_York'>US Eastern (UTC-4)</SelectItem>
<SelectItem value='America/Chicago'>US Central (UTC-5)</SelectItem>
<SelectItem value='America/Denver'>US Mountain (UTC-6)</SelectItem>
<SelectItem value='America/Los_Angeles'>US Pacific (UTC-7)</SelectItem>
<SelectItem value='Europe/London'>London (UTC+1)</SelectItem>
<SelectItem value='Europe/Paris'>Paris (UTC+2)</SelectItem>
<SelectItem value='Asia/Singapore'>Singapore (UTC+8)</SelectItem>
<SelectItem value='Asia/Tokyo'>Tokyo (UTC+9)</SelectItem>
<SelectItem value='Australia/Sydney'>Sydney (UTC+10)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<DialogFooter className='border-t px-6 py-4'>
<div className='flex w-full justify-between'>
<div>
{scheduleId && onDelete && (
<Button
type='button'
variant='destructive'
onClick={openDeleteConfirm}
disabled={isDeleting || isSaving}
size='default'
className='h-9 rounded-[8px]'
>
{isDeleting ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='mr-2 h-4 w-4' />
)}
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={handleClose}
size='default'
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving}
className={cn(
'w-[140px] rounded-[8px]',
hasChanges ? 'bg-primary hover:bg-primary/90' : ''
)}
size='sm'
>
{isSaving && (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
)}
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
<UnsavedChangesDialog
open={showUnsavedChangesConfirm}
setOpen={setShowUnsavedChangesConfirm}
onCancel={handleCancelClose}
onConfirm={handleConfirmClose}
/>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Schedule</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this schedule? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowDeleteConfirm(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
{isDeleting ? 'Deleting...' : 'Delete Schedule'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -1,440 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Calendar, ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Dialog } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { formatDateTime } from '@/lib/utils'
import { ScheduleModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { getBlockWithValues, getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ScheduleConfig')
interface ScheduleConfigProps {
blockId: string
subBlockId: string
isConnecting: boolean
isPreview?: boolean
previewValue?: any | null
disabled?: boolean
}
export function ScheduleConfig({
blockId,
subBlockId: _subBlockId,
isConnecting,
isPreview = false,
previewValue: _previewValue,
disabled = false,
}: ScheduleConfigProps) {
const [error, setError] = useState<string | null>(null)
const [scheduleData, setScheduleData] = useState<{
id: string | null
nextRunAt: string | null
lastRanAt: string | null
cronExpression: string | null
timezone: string
}>({
id: null,
nextRunAt: null,
lastRanAt: null,
cronExpression: null,
timezone: 'UTC',
})
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const params = useParams()
const workflowId = params.workflowId as string
// Get workflow state from store
// Get the schedule type from the block state
const [scheduleType] = useSubBlockValue(blockId, 'scheduleType')
// Get the startWorkflow value to determine if scheduling is enabled
// and expose the setter so we can update it
const [_startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
// Determine if this is a schedule trigger block vs starter block
const blockWithValues = getBlockWithValues(blockId)
const isScheduleTriggerBlock = blockWithValues?.type === 'schedule'
// Fetch schedule data from API
const fetchSchedule = useCallback(async () => {
if (!workflowId) return
setIsLoading(true)
try {
const params = new URLSearchParams({
workflowId,
mode: 'schedule',
})
if (isScheduleTriggerBlock) {
params.set('blockId', blockId)
}
const response = await fetch(`/api/schedules?${params}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
})
if (response.ok) {
const data = await response.json()
if (data.schedule) {
setScheduleData({
id: data.schedule.id,
nextRunAt: data.schedule.nextRunAt,
lastRanAt: data.schedule.lastRanAt,
cronExpression: data.schedule.cronExpression,
timezone: data.schedule.timezone || 'UTC',
})
} else {
setScheduleData({
id: null,
nextRunAt: null,
lastRanAt: null,
cronExpression: null,
timezone: 'UTC',
})
}
}
} catch (error) {
logger.error('Error fetching schedule:', error)
} finally {
setIsLoading(false)
}
}, [workflowId, blockId, isScheduleTriggerBlock])
// Fetch schedule data on mount and when dependencies change
useEffect(() => {
fetchSchedule()
}, [fetchSchedule])
// Separate effect for event listener to avoid removing/re-adding on every dependency change
useEffect(() => {
const handleScheduleUpdate = (event: CustomEvent) => {
if (event.detail?.workflowId === workflowId && event.detail?.blockId === blockId) {
logger.debug('Schedule update event received in schedule-config, refetching')
fetchSchedule()
}
}
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
return () => {
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
}
}, [workflowId, blockId, fetchSchedule])
// Refetch when modal opens to get latest data
useEffect(() => {
if (isModalOpen) {
fetchSchedule()
}
}, [isModalOpen, fetchSchedule])
// Format the schedule information for display
const getScheduleInfo = () => {
if (!scheduleData.id || !scheduleData.nextRunAt) return null
let scheduleTiming = 'Unknown schedule'
if (scheduleData.cronExpression) {
scheduleTiming = parseCronToHumanReadable(scheduleData.cronExpression, scheduleData.timezone)
} else if (scheduleType) {
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
}
return (
<>
<div className='truncate font-normal text-sm'>{scheduleTiming}</div>
<div className='text-muted-foreground text-xs'>
<div>
Next run: {formatDateTime(new Date(scheduleData.nextRunAt), scheduleData.timezone)}
</div>
{scheduleData.lastRanAt && (
<div>
Last run: {formatDateTime(new Date(scheduleData.lastRanAt), scheduleData.timezone)}
</div>
)}
</div>
</>
)
}
const handleOpenModal = () => {
if (isPreview || disabled) return
setIsModalOpen(true)
}
const handleCloseModal = useCallback(() => {
setIsModalOpen(false)
}, [])
const handleSaveSchedule = useCallback(async (): Promise<boolean> => {
if (isPreview || disabled) return false
setIsSaving(true)
setError(null)
try {
// For starter blocks, update the startWorkflow value to 'schedule'
// For schedule trigger blocks, skip this step as startWorkflow is not needed
if (!isScheduleTriggerBlock) {
// 1. First, update the startWorkflow value in SubBlock store to 'schedule'
setStartWorkflow('schedule')
// 2. Directly access and modify the SubBlock store to guarantee the value is set
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
}
// Update the SubBlock store directly to ensure the value is set correctly
const subBlockStore = useSubBlockStore.getState()
subBlockStore.setValue(blockId, 'startWorkflow', 'schedule')
// Give React time to process the state update
await new Promise((resolve) => setTimeout(resolve, 200))
}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
}
// 3. Get the fully merged current state with updated values
// This ensures we send the complete, correct workflow state to the backend
const currentWorkflowWithValues = getWorkflowWithValues(activeWorkflowId)
if (!currentWorkflowWithValues) {
setError('Failed to get current workflow state')
return false
}
// 4. Make a direct API call instead of relying on sync
// This gives us more control and better error handling
logger.debug('Making direct API call to save schedule with complete state')
// Prepare the request body
const requestBody: any = {
workflowId,
state: currentWorkflowWithValues.state,
}
// For schedule trigger blocks, include the blockId
if (isScheduleTriggerBlock) {
requestBody.blockId = blockId
}
const response = await fetch('/api/schedules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
// Parse the response
const responseText = await response.text()
let responseData
try {
responseData = JSON.parse(responseText)
} catch (e) {
logger.error('Failed to parse response JSON', e, responseText)
responseData = {}
}
if (!response.ok) {
setError(responseData.error || 'Failed to save schedule')
return false
}
logger.debug('Schedule save response:', responseData)
// 5. Update our local state with the response data
if (responseData.cronExpression || responseData.nextRunAt) {
setScheduleData((prev) => ({
...prev,
cronExpression: responseData.cronExpression || prev.cronExpression,
nextRunAt:
typeof responseData.nextRunAt === 'string'
? responseData.nextRunAt
: responseData.nextRunAt?.toISOString?.() || prev.nextRunAt,
}))
}
// 6. Dispatch custom event to notify parent workflow-block component to refetch schedule info
// This ensures the badge updates immediately after saving
const event = new CustomEvent('schedule-updated', {
detail: { workflowId, blockId },
})
window.dispatchEvent(event)
logger.debug('Dispatched schedule-updated event', { workflowId, blockId })
// 6. Update the schedule status and trigger a workflow update
// Note: Global schedule status is managed at a higher level
// 7. Tell the workflow store that the state has been saved
const workflowStore = useWorkflowStore.getState()
workflowStore.updateLastSaved()
workflowStore.triggerUpdate()
// 8. Refetch the schedule to update local state
await fetchSchedule()
return true
} catch (error) {
logger.error('Error saving schedule:', { error })
setError('Failed to save schedule')
return false
} finally {
setIsSaving(false)
}
}, [workflowId, blockId, isScheduleTriggerBlock, setStartWorkflow, fetchSchedule])
const handleDeleteSchedule = useCallback(async (): Promise<boolean> => {
if (isPreview || !scheduleData.id || disabled) return false
setIsDeleting(true)
try {
// For starter blocks, update the startWorkflow value to 'manual'
// For schedule trigger blocks, skip this step as startWorkflow is not relevant
if (!isScheduleTriggerBlock) {
// 1. First update the workflow state to disable scheduling
setStartWorkflow('manual')
// 2. Directly update the SubBlock store to ensure the value is set
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
}
// Update the store directly
const subBlockStore = useSubBlockStore.getState()
subBlockStore.setValue(blockId, 'startWorkflow', 'manual')
// 3. Update the workflow store
const workflowStore = useWorkflowStore.getState()
workflowStore.triggerUpdate()
workflowStore.updateLastSaved()
}
// 4. Make the DELETE API call to remove the schedule
const response = await fetch(`/api/schedules/${scheduleData.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
setError(data.error || 'Failed to delete schedule')
return false
}
// 5. Clear schedule state
setScheduleData({
id: null,
nextRunAt: null,
lastRanAt: null,
cronExpression: null,
timezone: 'UTC',
})
// 6. Update schedule status and refresh UI
// Note: Global schedule status is managed at a higher level
// 7. Dispatch custom event to notify parent workflow-block component
const event = new CustomEvent('schedule-updated', {
detail: { workflowId, blockId },
})
window.dispatchEvent(event)
logger.debug('Dispatched schedule-updated event after delete', { workflowId, blockId })
return true
} catch (error) {
logger.error('Error deleting schedule:', { error })
setError('Failed to delete schedule')
return false
} finally {
setIsDeleting(false)
}
}, [
scheduleData.id,
isPreview,
disabled,
isScheduleTriggerBlock,
setStartWorkflow,
workflowId,
blockId,
])
// Check if the schedule is active
const isScheduleActive = !!scheduleData.id && !!scheduleData.nextRunAt
return (
<div className='w-full' onClick={(e) => e.stopPropagation()}>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
{isScheduleActive ? (
<div className='flex flex-col space-y-2'>
<div className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'>
<div className='flex flex-1 items-center gap-2'>
<div className='flex-1 truncate'>{getScheduleInfo()}</div>
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 shrink-0'
onClick={handleOpenModal}
disabled={isPreview || isDeleting || isConnecting || disabled}
>
{isDeleting ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<ExternalLink className='h-4 w-4' />
)}
</Button>
</div>
</div>
) : (
<Button
variant='outline'
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isPreview || isConnecting || isSaving || isDeleting || disabled}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Calendar className='mr-2 h-4 w-4' />
)}
Configure Schedule
</Button>
)}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<ScheduleModal
isOpen={isModalOpen}
onClose={handleCloseModal}
workflowId={workflowId}
blockId={blockId}
onSave={handleSaveSchedule}
onDelete={handleDeleteSchedule}
scheduleId={scheduleData.id}
/>
</Dialog>
</div>
)
}
@@ -27,7 +27,6 @@ import {
McpToolSelector,
ProjectSelectorInput,
ResponseFormat,
ScheduleConfig,
ShortInput,
SliderInput,
Switch,
@@ -39,6 +38,7 @@ import {
VariablesInput,
WebhookConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
import { ScheduleSave } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule-save/schedule-save'
import type { SubBlockConfig } from '@/blocks/types'
import { DocumentTagEntry } from './components/document-tag-entry/document-tag-entry'
import { E2BSwitch } from './components/e2b-switch'
@@ -347,17 +347,6 @@ export const SubBlock = memo(
/>
)
}
case 'schedule-config':
return (
<ScheduleConfig
blockId={blockId}
subBlockId={config.id}
isConnecting={isConnecting}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
/>
)
case 'oauth-input':
return (
<CredentialSelector
@@ -545,6 +534,8 @@ export const SubBlock = memo(
}
/>
)
case 'schedule-save':
return <ScheduleSave blockId={blockId} isPreview={isPreview} disabled={disabled} />
case 'trigger-save':
return (
<TriggerSave
@@ -340,7 +340,7 @@ export const WorkflowBlock = memo(
setIsLoadingScheduleInfo(false) // Reset loading state when not a schedule block
}
// Listen for schedule updates from the schedule-config component
// Listen for schedule updates from the schedule-save component
const handleScheduleUpdate = (event: CustomEvent) => {
// Check if the update is for this workflow and block
if (event.detail?.workflowId === currentWorkflowId && event.detail?.blockId === id) {
@@ -487,7 +487,8 @@ export const WorkflowBlock = memo(
}
// Determine if this is a pure trigger block (category: 'triggers')
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
// Pure trigger blocks should always show their trigger-mode subblocks
const isPureTriggerBlock = config.category === 'triggers'
// When in trigger mode, filter out non-trigger subblocks
if (effectiveTrigger) {
@@ -503,7 +504,8 @@ export const WorkflowBlock = memo(
// Continue to condition check below - don't return here!
} else {
// When NOT in trigger mode, hide trigger-specific subblocks
if (block.mode === 'trigger') {
// EXCEPT for pure trigger blocks which should always show their trigger-mode subblocks
if (block.mode === 'trigger' && !isPureTriggerBlock) {
return false
}
}
@@ -864,54 +866,6 @@ export const WorkflowBlock = memo(
Disabled
</Badge>
)}
{/* Schedule indicator badge - displayed for starter blocks with active schedules */}
{shouldShowScheduleBadge && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant='outline'
className={cn(
'flex cursor-pointer items-center gap-1 font-normal text-xs',
scheduleInfo?.isDisabled
? 'border-amber-200 bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-900/20 dark:text-amber-400'
: 'border-green-200 bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-400'
)}
onClick={
scheduleInfo?.id
? scheduleInfo.isDisabled
? () => reactivateSchedule(scheduleInfo.id!)
: () => disableSchedule(scheduleInfo.id!)
: undefined
}
>
<div className='relative mr-0.5 flex items-center justify-center'>
<div
className={cn(
'absolute h-3 w-3 rounded-full',
scheduleInfo?.isDisabled ? 'bg-amber-500/20' : 'bg-green-500/20'
)}
/>
<div
className={cn(
'relative h-2 w-2 rounded-full',
scheduleInfo?.isDisabled ? 'bg-amber-500' : 'bg-green-500'
)}
/>
</div>
{scheduleInfo?.isDisabled ? 'Disabled' : 'Scheduled'}
</Badge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-4'>
{scheduleInfo?.isDisabled ? (
<p className='text-sm'>
This schedule is currently disabled. Click the badge to reactivate it.
</p>
) : (
<p className='text-sm'>Click the badge to disable this schedule.</p>
)}
</TooltipContent>
</Tooltip>
)}
{/* Webhook indicator badge - displayed for starter blocks with active webhooks */}
{showWebhookIndicator && (
<Tooltip>
+93 -30
View File
@@ -22,18 +22,10 @@ export const ScheduleBlock: BlockConfig = {
icon: ScheduleIcon,
subBlocks: [
// Schedule configuration status display
{
id: 'scheduleConfig',
title: 'Schedule Status',
type: 'schedule-config',
layout: 'full',
},
// Hidden fields for schedule configuration (used by the modal only)
{
id: 'scheduleType',
title: 'Frequency',
type: 'dropdown',
title: 'Run frequency',
layout: 'full',
options: [
{ label: 'Every X Minutes', id: 'minutes' },
@@ -41,30 +33,50 @@ export const ScheduleBlock: BlockConfig = {
{ label: 'Daily', id: 'daily' },
{ label: 'Weekly', id: 'weekly' },
{ label: 'Monthly', id: 'monthly' },
{ label: 'Custom Cron', id: 'custom' },
{ label: 'Custom (Cron)', id: 'custom' },
],
value: () => 'daily',
hidden: true,
required: true,
mode: 'trigger',
},
{
id: 'minutesInterval',
type: 'short-input',
hidden: true,
title: 'Interval (minutes)',
placeholder: '15',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'minutes' },
},
{
id: 'hourlyMinute',
type: 'short-input',
hidden: true,
title: 'Minute',
placeholder: '0-59',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'hourly' },
},
{
id: 'dailyTime',
type: 'short-input',
hidden: true,
type: 'time-input',
title: 'Time',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'daily' },
},
{
id: 'weeklyDay',
type: 'dropdown',
hidden: true,
title: 'Day of week',
layout: 'full',
options: [
{ label: 'Monday', id: 'MON' },
{ label: 'Tuesday', id: 'TUE' },
@@ -74,45 +86,96 @@ export const ScheduleBlock: BlockConfig = {
{ label: 'Saturday', id: 'SAT' },
{ label: 'Sunday', id: 'SUN' },
],
value: () => 'MON',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'weekly' },
},
{
id: 'weeklyDayTime',
type: 'short-input',
hidden: true,
type: 'time-input',
title: 'Time',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'weekly' },
},
{
id: 'monthlyDay',
type: 'short-input',
hidden: true,
title: 'Day of month',
placeholder: '1-31',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'monthly' },
},
{
id: 'monthlyTime',
type: 'short-input',
hidden: true,
type: 'time-input',
title: 'Time',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'monthly' },
},
{
id: 'cronExpression',
type: 'short-input',
hidden: true,
title: 'Cron expression',
placeholder: '0 0 * * *',
layout: 'full',
required: true,
mode: 'trigger',
condition: { field: 'scheduleType', value: 'custom' },
},
{
id: 'timezone',
type: 'dropdown',
hidden: true,
title: 'Timezone',
layout: 'full',
options: [
{ label: 'UTC', id: 'UTC' },
{ label: 'US Eastern (UTC-4)', id: 'America/New_York' },
{ label: 'US Central (UTC-5)', id: 'America/Chicago' },
{ label: 'US Mountain (UTC-6)', id: 'America/Denver' },
{ label: 'US Pacific (UTC-7)', id: 'America/Los_Angeles' },
{ label: 'London (UTC+1)', id: 'Europe/London' },
{ label: 'Paris (UTC+2)', id: 'Europe/Paris' },
{ label: 'US Eastern (UTC-5)', id: 'America/New_York' },
{ label: 'US Central (UTC-6)', id: 'America/Chicago' },
{ label: 'US Mountain (UTC-7)', id: 'America/Denver' },
{ label: 'US Pacific (UTC-8)', id: 'America/Los_Angeles' },
{ label: 'Mexico City (UTC-6)', id: 'America/Mexico_City' },
{ label: 'São Paulo (UTC-3)', id: 'America/Sao_Paulo' },
{ label: 'London (UTC+0)', id: 'Europe/London' },
{ label: 'Paris (UTC+1)', id: 'Europe/Paris' },
{ label: 'Berlin (UTC+1)', id: 'Europe/Berlin' },
{ label: 'Dubai (UTC+4)', id: 'Asia/Dubai' },
{ label: 'India (UTC+5:30)', id: 'Asia/Kolkata' },
{ label: 'Singapore (UTC+8)', id: 'Asia/Singapore' },
{ label: 'China (UTC+8)', id: 'Asia/Shanghai' },
{ label: 'Hong Kong (UTC+8)', id: 'Asia/Hong_Kong' },
{ label: 'Tokyo (UTC+9)', id: 'Asia/Tokyo' },
{ label: 'Sydney (UTC+10)', id: 'Australia/Sydney' },
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
],
value: () => 'UTC',
required: false,
mode: 'trigger',
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },
},
{
id: 'scheduleSave',
type: 'schedule-save',
layout: 'full',
mode: 'trigger',
},
{
id: 'scheduleId',
type: 'short-input',
hidden: true,
mode: 'trigger',
},
],
+1 -1
View File
@@ -53,7 +53,7 @@ export type SubBlockType =
| 'time-input' // Time input
| 'oauth-input' // OAuth credential selector
| 'webhook-config' // Webhook configuration
| 'schedule-config' // Schedule status and information
| 'schedule-save' // Schedule save button with status display
| 'file-selector' // File selector for Google Drive, etc.
| 'project-selector' // Project selector for Jira, Discord, etc.
| 'channel-selector' // Channel selector for Slack, Discord, etc.
+231
View File
@@ -0,0 +1,231 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useScheduleManagement')
interface UseScheduleManagementProps {
blockId: string
isPreview?: boolean
}
interface SaveConfigResult {
success: boolean
nextRunAt?: string
cronExpression?: string
}
interface ScheduleManagementState {
scheduleId: string | null
isLoading: boolean
isSaving: boolean
saveConfig: () => Promise<SaveConfigResult>
deleteConfig: () => Promise<boolean>
}
/**
* Hook to manage schedule lifecycle for schedule blocks
* Handles:
* - Loading existing schedules from the API
* - Saving schedule configurations
* - Deleting schedule configurations
*/
export function useScheduleManagement({
blockId,
isPreview = false,
}: UseScheduleManagementProps): ScheduleManagementState {
const params = useParams()
const workflowId = params.workflowId as string
const scheduleId = useSubBlockStore(
useCallback((state) => state.getValue(blockId, 'scheduleId') as string | null, [blockId])
)
const isLoading = useSubBlockStore((state) => state.loadingSchedules.has(blockId))
const isChecked = useSubBlockStore((state) => state.checkedSchedules.has(blockId))
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (isPreview) {
return
}
const store = useSubBlockStore.getState()
const currentlyLoading = store.loadingSchedules.has(blockId)
const alreadyChecked = store.checkedSchedules.has(blockId)
const currentScheduleId = store.getValue(blockId, 'scheduleId')
if (currentlyLoading || (alreadyChecked && currentScheduleId)) {
return
}
const loadSchedule = async () => {
useSubBlockStore.setState((state) => ({
loadingSchedules: new Set([...state.loadingSchedules, blockId]),
}))
try {
const response = await fetch(
`/api/schedules?workflowId=${workflowId}&blockId=${blockId}&mode=schedule`
)
if (response.ok) {
const data = await response.json()
if (data.schedule?.id) {
useSubBlockStore.getState().setValue(blockId, 'scheduleId', data.schedule.id)
logger.info('Schedule loaded from API', {
blockId,
scheduleId: data.schedule.id,
})
} else {
useSubBlockStore.getState().setValue(blockId, 'scheduleId', null)
}
useSubBlockStore.setState((state) => ({
checkedSchedules: new Set([...state.checkedSchedules, blockId]),
}))
} else {
logger.warn('API response not OK', {
blockId,
workflowId,
status: response.status,
statusText: response.statusText,
})
}
} catch (error) {
logger.error('Error loading schedule:', { error, blockId, workflowId })
} finally {
useSubBlockStore.setState((state) => {
const newSet = new Set(state.loadingSchedules)
newSet.delete(blockId)
return { loadingSchedules: newSet }
})
}
}
loadSchedule()
}, [isPreview, workflowId, blockId])
const saveConfig = async (): Promise<SaveConfigResult> => {
if (isPreview || isSaving) {
return { success: false }
}
try {
setIsSaving(true)
const workflowStore = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const subBlockValues = activeWorkflowId
? subBlockStore.workflowValues[activeWorkflowId] || {}
: {}
const { mergeSubblockStateAsync } = await import('@/stores/workflows/server-utils')
const mergedBlocks = await mergeSubblockStateAsync(workflowStore.blocks, subBlockValues)
const workflowState = {
blocks: mergedBlocks,
edges: workflowStore.edges,
loops: workflowStore.loops,
}
const response = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
blockId,
state: workflowState,
}),
})
if (!response.ok) {
let errorMessage = 'Failed to save schedule'
try {
const errorData = await response.json()
errorMessage = errorData.details || errorData.error || errorMessage
} catch {
// If response is not JSON, use default message
}
logger.error('Failed to save schedule', { errorMessage })
throw new Error(errorMessage)
}
const data = await response.json()
if (data.schedule?.id) {
useSubBlockStore.getState().setValue(blockId, 'scheduleId', data.schedule.id)
useSubBlockStore.setState((state) => ({
checkedSchedules: new Set([...state.checkedSchedules, blockId]),
}))
}
logger.info('Schedule saved successfully', {
scheduleId: data.schedule?.id,
blockId,
nextRunAt: data.nextRunAt,
cronExpression: data.cronExpression,
})
return { success: true, nextRunAt: data.nextRunAt, cronExpression: data.cronExpression }
} catch (error) {
logger.error('Error saving schedule:', error)
throw error
} finally {
setIsSaving(false)
}
}
const deleteConfig = async (): Promise<boolean> => {
if (isPreview || !scheduleId) {
return false
}
try {
setIsSaving(true)
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId: params.workspaceId as string,
}),
})
if (!response.ok) {
logger.error('Failed to delete schedule')
return false
}
useSubBlockStore.getState().setValue(blockId, 'scheduleId', null)
useSubBlockStore.setState((state) => {
const newSet = new Set(state.checkedSchedules)
newSet.delete(blockId)
return { checkedSchedules: newSet }
})
logger.info('Schedule deleted successfully')
return true
} catch (error) {
logger.error('Error deleting schedule:', error)
return false
} finally {
setIsSaving(false)
}
}
return {
scheduleId,
isLoading,
isSaving,
saveConfig,
deleteConfig,
}
}
-1
View File
@@ -206,7 +206,6 @@ export function useWebhookManagement({
}
loadWebhookOrGenerateUrl()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPreview, triggerId, workflowId, blockId])
const saveConfig = async (): Promise<boolean> => {
@@ -27,6 +27,8 @@ export const useSubBlockStore = create<SubBlockStore>()(
workflowValues: {},
loadingWebhooks: new Set<string>(),
checkedWebhooks: new Set<string>(),
loadingSchedules: new Set<string>(),
checkedSchedules: new Set<string>(),
setValue: (blockId: string, subBlockId: string, value: any) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
@@ -2,6 +2,8 @@ export interface SubBlockState {
workflowValues: Record<string, Record<string, Record<string, any>>> // Store values per workflow ID
loadingWebhooks: Set<string> // Track which blockIds are currently loading webhooks
checkedWebhooks: Set<string> // Track which blockIds have been checked for webhooks
loadingSchedules: Set<string> // Track which blockIds are currently loading schedules
checkedSchedules: Set<string> // Track which blockIds have been checked for schedules
}
export interface SubBlockStore extends SubBlockState {