import { useState, useCallback, useEffect } from "react"; import { Globe, Folder } from "lucide-react"; import type { ScheduledTask, ScheduledTaskCreateInput, ScheduleType, AutomationStep } from "@fusion/core"; import { ScheduleStepsEditor } from "./ScheduleStepsEditor"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { fetchModels } from "../api"; import type { ModelInfo } from "../api"; import type { SchedulingScope } from "./ScheduledTasksModal"; /** Mapping from preset schedule types to their cron expressions. Mirrored from @fusion/core. */ const PRESET_CRON: Record, string> = { hourly: "0 * * * *", daily: "0 0 * * *", weekly: "0 0 * * 1", monthly: "0 0 1 * *", every15Minutes: "*/15 * * * *", every30Minutes: "*/30 * * * *", every2Hours: "0 */2 * * *", every6Hours: "0 */6 * * *", every12Hours: "0 */12 * * *", weekdays: "0 9 * * 1-5", }; const SCHEDULE_TYPE_LABELS: Record = { hourly: "Every hour", daily: "Every day (midnight)", weekly: "Every week (Monday)", monthly: "Every month (1st)", custom: "Custom cron expression", every15Minutes: "Every 15 minutes", every30Minutes: "Every 30 minutes", every2Hours: "Every 2 hours", every6Hours: "Every 6 hours", every12Hours: "Every 12 hours", weekdays: "Weekdays at 9 AM (Mon-Fri)", }; /** * Simple cron expression validator (5-field format). * Checks basic structure — authoritative validation happens server-side. */ function isLikelyCron(expr: string): boolean { const parts = expr.trim().split(/\s+/); if (parts.length !== 5) return false; // Each field should contain digits, *, /, -, or , return parts.every((p) => /^[\d*,/-]+$/.test(p)); } /** * Generate a unique step ID using crypto.randomUUID with fallback. */ function generateStepId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } // Deterministic fallback: timestamp + random hex return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } type ScheduleMode = "simple" | "advanced"; type SimpleType = "command" | "ai-prompt" | "create-task"; interface ScheduleFormProps { /** Existing schedule for editing. Omit for create mode. */ schedule?: ScheduledTask; /** Called with form data on submit. */ onSubmit: (input: ScheduledTaskCreateInput) => Promise; /** Called when the user cancels. */ onCancel: () => void; /** Scope for the schedule (global or project). Defaults to schedule.scope or "project". */ scope?: SchedulingScope; /** Project ID for project-scoped schedules. */ projectId?: string; /** Called when the user changes the scope via the toggle buttons. */ onScopeChange?: (scope: SchedulingScope) => void; } export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, projectId, onScopeChange }: ScheduleFormProps) { const isEditing = !!schedule; // Determine initial mode based on whether the schedule has steps // But single ai-prompt and create-task steps from simple mode should show in simple mode const isSimpleAiPrompt = schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command; const isSimpleCreateTask = schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command; const initialMode: ScheduleMode = (schedule?.steps && schedule.steps.length > 0 && !isSimpleAiPrompt && !isSimpleCreateTask) ? "advanced" : "simple"; const [mode, setMode] = useState(initialMode); const [name, setName] = useState(schedule?.name ?? ""); const [description, setDescription] = useState(schedule?.description ?? ""); const [scheduleType, setScheduleType] = useState(schedule?.scheduleType ?? "daily"); const [cronExpression, setCronExpression] = useState(schedule?.cronExpression ?? ""); const [command, setCommand] = useState(schedule?.command ?? ""); const [enabled, setEnabled] = useState(schedule?.enabled ?? true); const [timeoutMs, setTimeoutMs] = useState(schedule?.timeoutMs ?? 300000); const [steps, setSteps] = useState(schedule?.steps ?? []); const [hasEditingSteps, setHasEditingSteps] = useState(false); // Scope toggle state const [localScope, setLocalScope] = useState(formScope ?? "global"); // Sync localScope when formScope prop changes (e.g., when parent resets) useEffect(() => { if (formScope) setLocalScope(formScope); }, [formScope]); // Simple mode type toggle state const [simpleType, setSimpleType] = useState(() => { // Detect if editing a simple-mode AI prompt schedule if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) { return "ai-prompt"; } // Detect if editing a simple-mode create-task schedule if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { return "create-task"; } return "command"; }); const [prompt, setPrompt] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) { return schedule.steps[0].prompt ?? ""; } return ""; }); const [modelProvider, setModelProvider] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) { return schedule.steps[0].modelProvider ?? ""; } if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { return schedule.steps[0].modelProvider ?? ""; } return ""; }); const [modelId, setModelId] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) { return schedule.steps[0].modelId ?? ""; } if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { return schedule.steps[0].modelId ?? ""; } return ""; }); // Create-task fields const [taskTitle, setTaskTitle] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { return schedule.steps[0].taskTitle ?? ""; } return ""; }); const [taskDescription, setTaskDescription] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { return schedule.steps[0].taskDescription ?? ""; } return ""; }); const [taskColumn, setTaskColumn] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { return schedule.steps[0].taskColumn ?? "triage"; } return "triage"; }); // Model dropdown state const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); // Fetch models for model dropdown useEffect(() => { let cancelled = false; setModelsLoading(true); setModelsError(null); fetchModels() .then((response) => { if (!cancelled) { setModels(response.models); } }) .catch((err: unknown) => { if (!cancelled) { setModelsError(err instanceof Error ? err.message : "Failed to load models"); } }) .finally(() => { if (!cancelled) { setModelsLoading(false); } }); return () => { cancelled = true; }; }, []); const [errors, setErrors] = useState>({}); const [submitting, setSubmitting] = useState(false); // Auto-fill cron expression when preset is selected useEffect(() => { if (scheduleType !== "custom") { setCronExpression(PRESET_CRON[scheduleType]); } }, [scheduleType]); // Compute combined model value from separate fields const modelValue = (modelProvider && modelId) ? `${modelProvider}/${modelId}` : ""; // Handle model selection from the dropdown const handleModelChange = useCallback((value: string) => { if (!value) { setModelProvider(""); setModelId(""); } else { const slashIdx = value.indexOf("/"); if (slashIdx !== -1) { setModelProvider(value.slice(0, slashIdx)); setModelId(value.slice(slashIdx + 1)); } } }, []); const validate = useCallback((): boolean => { const e: Record = {}; if (!name.trim()) e.name = "Name is required"; // Scope validation: project scope requires projectId if (localScope === "project" && !projectId) { e.scope = "Project-specific entries require an active project."; } // Simple mode validation if (mode === "simple") { if (simpleType === "command") { if (!command.trim()) e.command = "Command is required"; } else if (simpleType === "ai-prompt") { // AI Prompt mode if (!prompt.trim()) e.prompt = "Prompt is required"; // Model consistency check: both must be set or both must be empty const hasProvider = !!modelProvider.trim(); const hasModelId = !!modelId.trim(); if (hasProvider !== hasModelId) { e.model = "Both model provider and model ID must be set, or both must be empty"; } } else if (simpleType === "create-task") { // Create Task mode if (!taskDescription.trim()) e.taskDescription = "Task description is required"; // Model consistency check: both must be set or both must be empty const hasProvider = !!modelProvider.trim(); const hasModelId = !!modelId.trim(); if (hasProvider !== hasModelId) { e.model = "Both model provider and model ID must be set, or both must be empty"; } } } // Advanced mode validation if (mode === "advanced" && steps.length === 0) e.steps = "At least one step is required"; // Validate step content in multi-step mode if (mode === "advanced" && steps.length > 0) { const incompleteSteps: string[] = []; for (let i = 0; i < steps.length; i++) { const step = steps[i]; if (!step.name?.trim()) { incompleteSteps.push(`Step ${i + 1}: Name is required`); } if (step.type === "command" && !step.command?.trim()) { incompleteSteps.push(`Step ${i + 1}: Command is required`); } if (step.type === "ai-prompt" && !step.prompt?.trim()) { incompleteSteps.push(`Step ${i + 1}: Prompt is required`); } } if (incompleteSteps.length > 0) { e.steps = incompleteSteps.join("; "); } // Check if any steps are currently being edited if (hasEditingSteps) { e.stepsEditing = "Please save or cancel all step edits before saving the schedule"; } } if (scheduleType === "custom") { if (!cronExpression.trim()) { e.cronExpression = "Cron expression is required for custom schedules"; } else if (!isLikelyCron(cronExpression)) { e.cronExpression = "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')"; } } if (timeoutMs < 1000) { e.timeoutMs = "Timeout must be at least 1 second (1000ms)"; } setErrors(e); return Object.keys(e).length === 0; }, [name, command, prompt, modelProvider, modelId, mode, simpleType, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps, taskDescription, localScope]); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!validate()) return; setSubmitting(true); try { let submitData: ScheduledTaskCreateInput; // Determine scope: use edit mode's existing scope, otherwise use localScope // When localScope is "project" but no projectId provided, fall back to "global" let effectiveScope = schedule?.scope ?? localScope; if (effectiveScope === "project" && !projectId) { effectiveScope = "global"; } if (mode === "simple") { if (simpleType === "command") { submitData = { name: name.trim(), description: description.trim() || undefined, scheduleType, cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined, command: command.trim(), enabled, timeoutMs, steps: undefined, scope: effectiveScope, }; } else if (simpleType === "ai-prompt") { // AI Prompt mode - create a single-step automation const aiStep: AutomationStep = { id: generateStepId(), type: "ai-prompt", name: name.trim(), prompt: prompt.trim(), modelProvider: modelProvider.trim() || undefined, modelId: modelId.trim() || undefined, }; submitData = { name: name.trim(), description: description.trim() || undefined, scheduleType, cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined, command: "", enabled, timeoutMs, steps: [aiStep], scope: effectiveScope, }; } else { // Create Task mode - create a single-step create-task automation const createTaskStep: AutomationStep = { id: generateStepId(), type: "create-task", name: name.trim(), taskTitle: taskTitle.trim() || undefined, taskDescription: taskDescription.trim(), taskColumn: taskColumn, modelProvider: modelProvider.trim() || undefined, modelId: modelId.trim() || undefined, }; submitData = { name: name.trim(), description: description.trim() || undefined, scheduleType, cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined, command: "", enabled, timeoutMs, steps: [createTaskStep], scope: effectiveScope, }; } } else { submitData = { name: name.trim(), description: description.trim() || undefined, scheduleType, cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined, command: "", enabled, timeoutMs, steps, scope: effectiveScope, }; } await onSubmit(submitData); } finally { setSubmitting(false); } }, [validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps, localScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn], ); const cronFieldId = "schedule-cron"; const cronErrorId = "schedule-cron-error"; const nameErrorId = "schedule-name-error"; const commandErrorId = "schedule-command-error"; const promptErrorId = "schedule-prompt-error"; const modelErrorId = "schedule-model-error"; const taskDescriptionErrorId = "schedule-task-description-error"; const taskModelErrorId = "schedule-task-model-error"; const timeoutErrorId = "schedule-timeout-error"; return (

{isEditing ? "Edit Schedule" : "New Schedule"}

setName(e.target.value)} aria-invalid={!!errors.name} aria-describedby={errors.name ? nameErrorId : undefined} /> {errors.name && ( {errors.name} )}