mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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,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')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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'
|
||||
|
||||
+554
@@ -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>
|
||||
)
|
||||
}
|
||||
-634
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-440
@@ -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>
|
||||
)
|
||||
}
|
||||
+3
-12
@@ -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
|
||||
|
||||
+5
-51
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user