fix(FN-1952): recover routines schedules merge
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers } from "lucide-react";
|
||||
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
|
||||
|
||||
/**
|
||||
@@ -224,6 +224,17 @@ export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, runnin
|
||||
</div>
|
||||
|
||||
<div className="routine-card-meta">
|
||||
{routine.steps && routine.steps.length > 0 ? (
|
||||
<div className="routine-meta-item">
|
||||
<Layers size={12} />
|
||||
<span className="routine-policy-badge">{routine.steps.length} step{routine.steps.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
) : routine.command ? (
|
||||
<div className="routine-meta-item routine-meta-command-preview" title={routine.command}>
|
||||
<code className="routine-cron">{routine.command}</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Cron expression for cron triggers */}
|
||||
{routine.trigger.type === "cron" && cronExpression && (
|
||||
<div className="routine-meta-item">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
|
||||
import type {
|
||||
Routine,
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
RoutineManualTrigger,
|
||||
RoutineCatchUpPolicy,
|
||||
RoutineExecutionPolicy,
|
||||
AutomationStep,
|
||||
} from "@fusion/core";
|
||||
import { ScheduleStepsEditor } from "./ScheduleStepsEditor";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { fetchModels, type ModelInfo } from "../api";
|
||||
|
||||
type CronPresetType = "hourly" | "daily" | "weekly" | "monthly" | "custom";
|
||||
|
||||
@@ -141,6 +145,16 @@ const CATCH_UP_POLICY_OPTIONS: { value: RoutineCatchUpPolicy; label: string }[]
|
||||
{ value: "run", label: "Run all missed runs" },
|
||||
];
|
||||
|
||||
function generateStepId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
type ActionMode = "simple" | "advanced";
|
||||
type SimpleActionType = "command" | "ai-prompt" | "create-task";
|
||||
|
||||
interface RoutineEditorProps {
|
||||
/** Existing routine for editing. Omit for create mode. */
|
||||
routine?: Routine;
|
||||
@@ -184,10 +198,72 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
|
||||
routine?.catchUpPolicy ?? "run_one"
|
||||
);
|
||||
const [enabled, setEnabled] = useState(routine?.enabled ?? true);
|
||||
const isSimpleAiPrompt = routine?.steps && routine.steps.length === 1 &&
|
||||
routine.steps[0].type === "ai-prompt" && !routine.command;
|
||||
const isSimpleCreateTask = routine?.steps && routine.steps.length === 1 &&
|
||||
routine.steps[0].type === "create-task" && !routine.command;
|
||||
const [actionMode, setActionMode] = useState<ActionMode>(
|
||||
routine?.steps && routine.steps.length > 0 && !isSimpleAiPrompt && !isSimpleCreateTask ? "advanced" : "simple"
|
||||
);
|
||||
const [simpleActionType, setSimpleActionType] = useState<SimpleActionType>(() => {
|
||||
if (isSimpleAiPrompt) return "ai-prompt";
|
||||
if (isSimpleCreateTask) return "create-task";
|
||||
return "command";
|
||||
});
|
||||
const [command, setCommand] = useState(routine?.command ?? "");
|
||||
const [steps, setSteps] = useState<AutomationStep[]>(routine?.steps ?? []);
|
||||
const [hasEditingSteps, setHasEditingSteps] = useState(false);
|
||||
const [timeoutMs, setTimeoutMs] = useState<number>(routine?.timeoutMs ?? 300000);
|
||||
const [prompt, setPrompt] = useState(isSimpleAiPrompt ? routine.steps?.[0]?.prompt ?? "" : "");
|
||||
const [taskTitle, setTaskTitle] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskTitle ?? "" : "");
|
||||
const [taskDescription, setTaskDescription] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskDescription ?? "" : "");
|
||||
const [taskColumn, setTaskColumn] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskColumn ?? "triage" : "triage");
|
||||
const [modelProvider, setModelProvider] = useState(
|
||||
isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelProvider ?? "" : ""
|
||||
);
|
||||
const [modelId, setModelId] = useState(
|
||||
isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelId ?? "" : ""
|
||||
);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
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 modelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : "";
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setModelProvider("");
|
||||
setModelId("");
|
||||
return;
|
||||
}
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
setModelProvider(value.slice(0, slashIdx));
|
||||
setModelId(value.slice(slashIdx + 1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const validate = useCallback((): boolean => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!name.trim()) e.name = "Name is required";
|
||||
@@ -210,9 +286,29 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
|
||||
if (triggerType === "api" && !endpoint.trim()) {
|
||||
e.endpoint = "API endpoint is required";
|
||||
}
|
||||
if (actionMode === "simple") {
|
||||
if (simpleActionType === "command" && !command.trim()) e.command = "Command is required";
|
||||
if (simpleActionType === "ai-prompt" && !prompt.trim()) e.prompt = "Prompt is required";
|
||||
if (simpleActionType === "create-task" && !taskDescription.trim()) e.taskDescription = "Task description is required";
|
||||
if ((modelProvider.trim() && !modelId.trim()) || (!modelProvider.trim() && modelId.trim())) {
|
||||
e.model = "Both model provider and model ID must be set, or both must be empty";
|
||||
}
|
||||
} else {
|
||||
if (steps.length === 0) e.steps = "At least one step is required";
|
||||
if (hasEditingSteps) e.stepsEditing = "Please save or cancel all step edits before saving the routine";
|
||||
const incompleteSteps: string[] = [];
|
||||
steps.forEach((step, index) => {
|
||||
if (!step.name?.trim()) incompleteSteps.push(`Step ${index + 1}: Name is required`);
|
||||
if (step.type === "command" && !step.command?.trim()) incompleteSteps.push(`Step ${index + 1}: Command is required`);
|
||||
if (step.type === "ai-prompt" && !step.prompt?.trim()) incompleteSteps.push(`Step ${index + 1}: Prompt is required`);
|
||||
if (step.type === "create-task" && !step.taskDescription?.trim()) incompleteSteps.push(`Step ${index + 1}: Task description is required`);
|
||||
});
|
||||
if (incompleteSteps.length > 0) e.steps = incompleteSteps.join("; ");
|
||||
}
|
||||
if (timeoutMs < 1000) e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId]);
|
||||
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
@@ -228,11 +324,43 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
|
||||
}
|
||||
|
||||
const trigger = buildTrigger(triggerType, cronExpression, webhookPath, webhookSecret, endpoint);
|
||||
let actionCommand: string | undefined;
|
||||
let actionSteps: AutomationStep[] | undefined;
|
||||
if (actionMode === "simple") {
|
||||
if (simpleActionType === "command") {
|
||||
actionCommand = command.trim() || undefined;
|
||||
} else if (simpleActionType === "ai-prompt") {
|
||||
actionSteps = [{
|
||||
id: generateStepId(),
|
||||
type: "ai-prompt",
|
||||
name: name.trim(),
|
||||
prompt: prompt.trim(),
|
||||
modelProvider: modelProvider.trim() || undefined,
|
||||
modelId: modelId.trim() || undefined,
|
||||
}];
|
||||
} else {
|
||||
actionSteps = [{
|
||||
id: generateStepId(),
|
||||
type: "create-task",
|
||||
name: name.trim(),
|
||||
taskTitle: taskTitle.trim() || undefined,
|
||||
taskDescription: taskDescription.trim(),
|
||||
taskColumn,
|
||||
modelProvider: modelProvider.trim() || undefined,
|
||||
modelId: modelId.trim() || undefined,
|
||||
}];
|
||||
}
|
||||
} else {
|
||||
actionSteps = steps;
|
||||
}
|
||||
const input: RoutineCreateInput = {
|
||||
name: name.trim(),
|
||||
agentId: routine?.agentId ?? "",
|
||||
description: description.trim() || undefined,
|
||||
trigger,
|
||||
command: actionCommand,
|
||||
steps: actionSteps,
|
||||
timeoutMs,
|
||||
executionPolicy,
|
||||
catchUpPolicy,
|
||||
enabled,
|
||||
@@ -243,13 +371,18 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope],
|
||||
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope, routine?.agentId],
|
||||
);
|
||||
|
||||
const nameErrorId = "routine-name-error";
|
||||
const cronErrorId = "routine-cron-error";
|
||||
const webhookErrorId = "routine-webhook-error";
|
||||
const endpointErrorId = "routine-endpoint-error";
|
||||
const commandErrorId = "routine-command-error";
|
||||
const promptErrorId = "routine-prompt-error";
|
||||
const taskDescriptionErrorId = "routine-task-description-error";
|
||||
const modelErrorId = "routine-model-error";
|
||||
const timeoutErrorId = "routine-timeout-error";
|
||||
|
||||
const handleCronPresetChange = useCallback((preset: CronPresetType) => {
|
||||
setCronPreset(preset);
|
||||
@@ -481,6 +614,103 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Action Mode</label>
|
||||
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Action mode">
|
||||
<button
|
||||
type="button"
|
||||
className={`schedule-mode-btn${actionMode === "simple" ? " active" : ""}`}
|
||||
onClick={() => setActionMode("simple")}
|
||||
role="radio"
|
||||
aria-checked={actionMode === "simple"}
|
||||
>
|
||||
Simple
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`schedule-mode-btn${actionMode === "advanced" ? " active" : ""}`}
|
||||
onClick={() => setActionMode("advanced")}
|
||||
role="radio"
|
||||
aria-checked={actionMode === "advanced"}
|
||||
>
|
||||
Multi-Step
|
||||
</button>
|
||||
</div>
|
||||
<small>{actionMode === "simple" ? "Run one command, prompt, or task creation action" : "Run multiple actions sequentially"}</small>
|
||||
</div>
|
||||
|
||||
{actionMode === "simple" ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Action Type</label>
|
||||
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Action type">
|
||||
<button type="button" className={`schedule-mode-btn${simpleActionType === "command" ? " active" : ""}`} onClick={() => setSimpleActionType("command")} role="radio" aria-checked={simpleActionType === "command"}>Command</button>
|
||||
<button type="button" className={`schedule-mode-btn${simpleActionType === "ai-prompt" ? " active" : ""}`} onClick={() => setSimpleActionType("ai-prompt")} role="radio" aria-checked={simpleActionType === "ai-prompt"}>AI Prompt</button>
|
||||
<button type="button" className={`schedule-mode-btn${simpleActionType === "create-task" ? " active" : ""}`} onClick={() => setSimpleActionType("create-task")} role="radio" aria-checked={simpleActionType === "create-task"}>Create Task</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simpleActionType === "command" ? (
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-command">Command</label>
|
||||
<input id="routine-command" type="text" placeholder="e.g. fn backup --create" value={command} onChange={(e) => setCommand(e.target.value)} aria-invalid={!!errors.command} aria-describedby={errors.command ? commandErrorId : undefined} />
|
||||
{errors.command ? <small id={commandErrorId} className="field-error">{errors.command}</small> : <small>Shell command to execute.</small>}
|
||||
</div>
|
||||
) : simpleActionType === "ai-prompt" ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-prompt">Prompt</label>
|
||||
<textarea id="routine-prompt" placeholder="e.g. Summarize recent activity and create action items" value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={3} aria-invalid={!!errors.prompt} aria-describedby={errors.prompt ? promptErrorId : undefined} />
|
||||
{errors.prompt ? <small id={promptErrorId} className="field-error">{errors.prompt}</small> : <small>AI prompt to execute.</small>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-model">Model (optional)</label>
|
||||
<CustomModelDropdown id="routine-model" label="Model" models={models} value={modelValue} onChange={handleModelChange} placeholder="Use default" disabled={modelsLoading} />
|
||||
{modelsError && <small className="field-error">{modelsError}</small>}
|
||||
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-task-title">Task Title (optional)</label>
|
||||
<input id="routine-task-title" type="text" placeholder="e.g. Review weekly dependencies" value={taskTitle} onChange={(e) => setTaskTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-task-description">Task Description</label>
|
||||
<textarea id="routine-task-description" placeholder="e.g. Check npm dependencies for security vulnerabilities" value={taskDescription} onChange={(e) => setTaskDescription(e.target.value)} rows={4} aria-invalid={!!errors.taskDescription} aria-describedby={errors.taskDescription ? taskDescriptionErrorId : undefined} />
|
||||
{errors.taskDescription ? <small id={taskDescriptionErrorId} className="field-error">{errors.taskDescription}</small> : <small>Describes the task that will be created.</small>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-task-column">Target Column</label>
|
||||
<select id="routine-task-column" value={taskColumn} onChange={(e) => setTaskColumn(e.target.value)}>
|
||||
<option value="triage">Triage</option>
|
||||
<option value="todo">To Do</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-task-model">Executor Model (optional)</label>
|
||||
<CustomModelDropdown id="routine-task-model" label="Executor Model" models={models} value={modelValue} onChange={handleModelChange} placeholder="Use default" disabled={modelsLoading} />
|
||||
{modelsError && <small className="field-error">{modelsError}</small>}
|
||||
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScheduleStepsEditor steps={steps} onChange={setSteps} onEditingChange={setHasEditingSteps} />
|
||||
{errors.steps && <small className="field-error">{errors.steps}</small>}
|
||||
{errors.stepsEditing && <small className="field-error">{errors.stepsEditing}</small>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-timeout">Timeout (ms)</label>
|
||||
<input id="routine-timeout" type="number" min={1000} step={1000} value={timeoutMs} onChange={(e) => setTimeoutMs(Number(e.target.value))} aria-invalid={!!errors.timeoutMs} aria-describedby={errors.timeoutMs ? timeoutErrorId : undefined} />
|
||||
{errors.timeoutMs ? <small id={timeoutErrorId} className="field-error">{errors.timeoutMs}</small> : <small>Maximum execution time in milliseconds.</small>}
|
||||
</div>
|
||||
|
||||
{/* Execution Policy */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="routine-execution-policy">Execution Policy</label>
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Plus, Clock, Zap, Globe, Folder } from "lucide-react";
|
||||
import type {
|
||||
ScheduledTask,
|
||||
ScheduledTaskCreateInput,
|
||||
Routine,
|
||||
RoutineCreateInput,
|
||||
} from "@fusion/core";
|
||||
import { Plus, Zap, Globe, Folder } from "lucide-react";
|
||||
import type { Routine, RoutineCreateInput } from "@fusion/core";
|
||||
import {
|
||||
fetchAutomations,
|
||||
createAutomation,
|
||||
updateAutomation,
|
||||
deleteAutomation,
|
||||
runAutomation,
|
||||
toggleAutomation,
|
||||
fetchRoutines,
|
||||
createRoutine,
|
||||
updateRoutine,
|
||||
deleteRoutine,
|
||||
runRoutine,
|
||||
} from "../api";
|
||||
import { ScheduleForm } from "./ScheduleForm";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import { RoutineCard } from "./RoutineCard";
|
||||
import { RoutineEditor } from "./RoutineEditor";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -38,24 +25,10 @@ interface ScheduledTasksModalProps {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
type ModalView = "list" | "create" | "edit";
|
||||
type ActiveTab = "schedules" | "routines";
|
||||
|
||||
export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledTasksModalProps) {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>("schedules");
|
||||
|
||||
// Scope state: defaults to "project" when projectId exists, else "global"
|
||||
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
|
||||
|
||||
// Schedule state
|
||||
const [schedules, setSchedules] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<ModalView>("list");
|
||||
const [editingSchedule, setEditingSchedule] = useState<ScheduledTask | undefined>();
|
||||
/** Track which schedule is currently running a manual execution. */
|
||||
const [runningId, setRunningId] = useState<string | null>(null);
|
||||
|
||||
// Routine state
|
||||
const [routines, setRoutines] = useState<Routine[]>([]);
|
||||
const [routineView, setRoutineView] = useState<"list" | "create" | "edit">("list");
|
||||
@@ -68,18 +41,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
projectId: activeScope === "project" ? projectId : undefined,
|
||||
}), [activeScope, projectId]);
|
||||
|
||||
// Load schedules
|
||||
const loadSchedules = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchAutomations(scopeOptions);
|
||||
setSchedules(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load schedules", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast, scopeOptions]);
|
||||
|
||||
// Load routines
|
||||
const loadRoutines = useCallback(async () => {
|
||||
try {
|
||||
@@ -91,44 +52,32 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
}, [addToast, scopeOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSchedules();
|
||||
void loadRoutines();
|
||||
}, [loadSchedules, loadRoutines]);
|
||||
}, [loadRoutines]);
|
||||
|
||||
// Poll for updates while modal is open
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void loadSchedules();
|
||||
void loadRoutines();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSchedules, loadRoutines]);
|
||||
}, [loadRoutines]);
|
||||
|
||||
// Close on Escape (only when not in a sub-form)
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (activeTab === "schedules") {
|
||||
if (view !== "list") {
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
if (routineView !== "list") {
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
} else {
|
||||
// Routines tab
|
||||
if (routineView !== "list") {
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, activeTab, view, routineView]);
|
||||
}, [onClose, routineView]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -137,97 +86,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// ── Schedule CRUD handlers ──────────────────────────────────────────────
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (input: ScheduledTaskCreateInput) => {
|
||||
try {
|
||||
await createAutomation(input, scopeOptions);
|
||||
addToast("Schedule created", "success");
|
||||
setView("list");
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((schedule: ScheduledTask) => {
|
||||
setEditingSchedule(schedule);
|
||||
setView("edit");
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (input: ScheduledTaskCreateInput) => {
|
||||
if (!editingSchedule) return;
|
||||
try {
|
||||
await updateAutomation(editingSchedule.id, input, scopeOptions);
|
||||
addToast("Schedule updated", "success");
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to update schedule", "error");
|
||||
}
|
||||
},
|
||||
[editingSchedule, addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
try {
|
||||
await deleteAutomation(schedule.id, scopeOptions);
|
||||
addToast(`Deleted "${schedule.name}"`, "success");
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
setRunningId(schedule.id);
|
||||
try {
|
||||
const { result } = await runAutomation(schedule.id, scopeOptions);
|
||||
if (result.success) {
|
||||
addToast(`"${schedule.name}" completed successfully`, "success");
|
||||
} else {
|
||||
addToast(`"${schedule.name}" failed: ${result.error || "Unknown error"}`, "error");
|
||||
}
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to run schedule", "error");
|
||||
} finally {
|
||||
setRunningId(null);
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
try {
|
||||
await toggleAutomation(schedule.id, scopeOptions);
|
||||
addToast(
|
||||
`"${schedule.name}" ${schedule.enabled ? "disabled" : "enabled"}`,
|
||||
"success",
|
||||
);
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to toggle schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleFormCancel = useCallback(() => {
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Routine CRUD handlers ───────────────────────────────────────────────
|
||||
|
||||
const handleCreateRoutine = useCallback(
|
||||
@@ -319,86 +177,17 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
setEditingRoutine(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Tab switch handlers ─────────────────────────────────────────────────
|
||||
|
||||
const handleTabSwitch = useCallback((tab: ActiveTab) => {
|
||||
setActiveTab(tab);
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Scope switch handler ───────────────────────────────────────────────
|
||||
|
||||
const handleScopeSwitch = useCallback((scope: SchedulingScope) => {
|
||||
setActiveScope(scope);
|
||||
// Reset to list view when switching scope
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Render content ─────────────────────────────────────────────────────
|
||||
|
||||
const renderSchedulesContent = () => {
|
||||
if (view === "create") {
|
||||
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} scope={activeScope} projectId={projectId} onScopeChange={handleScopeSwitch} />;
|
||||
}
|
||||
|
||||
if (view === "edit" && editingSchedule) {
|
||||
return (
|
||||
<ScheduleForm
|
||||
schedule={editingSchedule}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={handleFormCancel}
|
||||
scope={activeScope}
|
||||
projectId={projectId}
|
||||
onScopeChange={handleScopeSwitch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
if (loading) {
|
||||
return <div className="settings-empty-state settings-loading">Loading schedules…</div>;
|
||||
}
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return (
|
||||
<div className="schedule-empty-state">
|
||||
<Clock size={48} strokeWidth={1} />
|
||||
<h4>No scheduled tasks yet</h4>
|
||||
<p>Create a schedule to automate recurring tasks.</p>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setView("create")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create your first schedule
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="schedule-list">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard
|
||||
key={s.id}
|
||||
schedule={s}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRun={handleRun}
|
||||
onToggle={handleToggle}
|
||||
running={runningId === s.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRoutinesContent = () => {
|
||||
if (routineView === "create") {
|
||||
return <RoutineEditor onSubmit={handleCreateRoutine} onCancel={handleRoutineCancel} scope={activeScope} projectId={projectId} />;
|
||||
@@ -421,14 +210,14 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
return (
|
||||
<div className="routine-empty-state">
|
||||
<Zap size={48} strokeWidth={1} />
|
||||
<h4>No routines yet</h4>
|
||||
<p>Create a routine to assign recurring tasks to agents.</p>
|
||||
<h4>No automations yet</h4>
|
||||
<p>Create an automation with a schedule, webhook, API, or manual trigger.</p>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setRoutineView("create")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create your first routine
|
||||
Create your first automation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -452,23 +241,17 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeTab === "schedules") {
|
||||
return renderSchedulesContent();
|
||||
}
|
||||
return renderRoutinesContent();
|
||||
};
|
||||
|
||||
// Determine if we're in "list" view for showing the "New" button
|
||||
const isShowingList =
|
||||
activeTab === "schedules" ? view === "list" && schedules.length > 0 : routineView === "list" && routines.length > 0;
|
||||
const isShowingEmptyState =
|
||||
activeTab === "schedules" ? view === "list" && schedules.length === 0 && !loading : routineView === "list" && routines.length === 0;
|
||||
|
||||
routineView === "list" && routines.length > 0;
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
|
||||
<div className="modal-header">
|
||||
<h3 id="schedules-modal-title">Scheduled Tasks</h3>
|
||||
<h3 id="schedules-modal-title">Automations</h3>
|
||||
<div className="modal-header-actions">
|
||||
{/* Scope selector */}
|
||||
<div className="scheduling-scope-selector" role="group" aria-label="Scheduling scope">
|
||||
@@ -477,7 +260,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
className={`scope-btn${activeScope === "global" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("global")}
|
||||
aria-pressed={activeScope === "global"}
|
||||
title="Global (user-level) schedules"
|
||||
title="Global (user-level) automations"
|
||||
>
|
||||
<Globe size={14} />
|
||||
Global
|
||||
@@ -487,7 +270,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
className={`scope-btn${activeScope === "project" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("project")}
|
||||
aria-pressed={activeScope === "project"}
|
||||
title="Project-scoped schedules"
|
||||
title="Project-scoped automations"
|
||||
>
|
||||
<Folder size={14} />
|
||||
Project
|
||||
@@ -496,17 +279,11 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
{isShowingList && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
if (activeTab === "schedules") {
|
||||
setView("create");
|
||||
} else {
|
||||
setRoutineView("create");
|
||||
}
|
||||
}}
|
||||
aria-label={activeTab === "schedules" ? "Create new schedule" : "Create new routine"}
|
||||
onClick={() => setRoutineView("create")}
|
||||
aria-label="Create new automation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{activeTab === "schedules" ? "New Schedule" : "New Routine"}
|
||||
New Automation
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
@@ -515,25 +292,21 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="scheduling-summary" aria-live="polite">
|
||||
<Zap size={14} />
|
||||
<span>{routines.length} automation{routines.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<div className="detail-tabs" role="tablist">
|
||||
<button
|
||||
className={`detail-tab${activeTab === "schedules" ? " detail-tab-active" : ""}`}
|
||||
role="tab"
|
||||
id="tab-schedules"
|
||||
aria-selected={activeTab === "schedules"}
|
||||
aria-controls="scheduled-tasks-content"
|
||||
onClick={() => handleTabSwitch("schedules")}
|
||||
>
|
||||
<Clock size={14} /> Schedules
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "routines" ? " detail-tab-active" : ""}`}
|
||||
className="detail-tab detail-tab-active"
|
||||
role="tab"
|
||||
id="tab-routines"
|
||||
aria-selected={activeTab === "routines"}
|
||||
aria-selected="true"
|
||||
aria-controls="scheduled-tasks-content"
|
||||
onClick={() => handleTabSwitch("routines")}
|
||||
onClick={() => {
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
}}
|
||||
>
|
||||
<Zap size={14} /> Routines
|
||||
</button>
|
||||
|
||||
@@ -130,6 +130,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
// Clean up localStorage
|
||||
localStorage.removeItem("kb-onboarding-state");
|
||||
});
|
||||
@@ -1486,20 +1487,22 @@ describe("ModelOnboardingModal", () => {
|
||||
});
|
||||
|
||||
// Click Login
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(screen.getByText("Login"));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
// Wait for the login to complete (poll detects authenticated on 2nd call)
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
|
||||
}, { timeout: 3000 });
|
||||
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
|
||||
|
||||
// Check that login outcome was persisted
|
||||
await waitFor(() => {
|
||||
const saveCall = mockSaveOnboardingState.mock.calls.find(
|
||||
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
const saveCall = mockSaveOnboardingState.mock.calls.find(
|
||||
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("login outcome persisted to stepData after successful login", async () => {
|
||||
@@ -1532,15 +1535,19 @@ describe("ModelOnboardingModal", () => {
|
||||
});
|
||||
|
||||
// Click Login
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(screen.getByText("Login"));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
// Wait for saveOnboardingState to be called with success outcome
|
||||
await waitFor(() => {
|
||||
const successCall = mockSaveOnboardingState.mock.calls.find(
|
||||
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
|
||||
);
|
||||
expect(successCall).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
const successCall = mockSaveOnboardingState.mock.calls.find(
|
||||
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
|
||||
);
|
||||
expect(successCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("stale pending outcomes are filtered on mount", async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
name: "Test Routine",
|
||||
description: "A test routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
command: "echo test",
|
||||
executionPolicy: "parallel",
|
||||
catchUpPolicy: "skip",
|
||||
enabled: true,
|
||||
@@ -37,6 +38,10 @@ describe("RoutineEditor", () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const fillCommand = (value = "echo test") => {
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value } });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -286,6 +291,7 @@ describe("RoutineEditor", () => {
|
||||
it("calls onSubmit with correct RoutineCreateInput shape on valid create", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fillCommand();
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
@@ -304,6 +310,7 @@ describe("RoutineEditor", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Weekly Routine" } });
|
||||
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "weekly" } });
|
||||
fillCommand();
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -335,6 +342,7 @@ describe("RoutineEditor", () => {
|
||||
const slowSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
render(<RoutineEditor onSubmit={slowSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fillCommand();
|
||||
|
||||
// Use role and name to find the submit button specifically
|
||||
const submitButton = screen.getByRole("button", { name: "Create Routine" });
|
||||
@@ -349,6 +357,7 @@ describe("RoutineEditor", () => {
|
||||
it("re-enables submit button after submission completes", async () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fillCommand();
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -364,6 +373,7 @@ describe("RoutineEditor", () => {
|
||||
fireEvent.click(screen.getByText("Webhook"));
|
||||
fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "/trigger/my-hook" } });
|
||||
fireEvent.change(screen.getByLabelText("Webhook Secret (optional)"), { target: { value: "my-secret" } });
|
||||
fillCommand();
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -384,6 +394,7 @@ describe("RoutineEditor", () => {
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "API Routine" } });
|
||||
fireEvent.click(screen.getByText("API"));
|
||||
fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "/api/my-routine" } });
|
||||
fillCommand();
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -402,6 +413,7 @@ describe("RoutineEditor", () => {
|
||||
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Manual Routine" } });
|
||||
fireEvent.click(screen.getByText("Manual"));
|
||||
fillCommand();
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScheduledTasksModal } from "../ScheduledTasksModal";
|
||||
import type { ScheduledTask, AutomationRunResult, Routine } from "@fusion/core";
|
||||
import type { Routine } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Plus: () => <span data-testid="icon-plus">+</span>,
|
||||
Clock: (props: any) => <span data-testid="icon-clock" style={props.strokeWidth ? {} : {}}>🕐</span>,
|
||||
Play: () => <span data-testid="icon-play">▶</span>,
|
||||
Pause: () => <span data-testid="icon-pause">⏸</span>,
|
||||
Pencil: () => <span data-testid="icon-pencil">✎</span>,
|
||||
Trash2: () => <span data-testid="icon-trash">🗑</span>,
|
||||
CheckCircle: () => <span data-testid="icon-check">✓</span>,
|
||||
XCircle: () => <span data-testid="icon-x">✗</span>,
|
||||
ChevronDown: () => <span data-testid="icon-down">▼</span>,
|
||||
ChevronUp: () => <span data-testid="icon-up">▲</span>,
|
||||
Calendar: () => <span data-testid="icon-calendar">📅</span>,
|
||||
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
|
||||
Code: () => <span data-testid="icon-code">💻</span>,
|
||||
Zap: () => <span data-testid="icon-zap">⚡</span>,
|
||||
Globe: () => <span data-testid="icon-globe">🌍</span>,
|
||||
Folder: () => <span data-testid="icon-folder">📁</span>,
|
||||
Clock: () => <span data-testid="icon-clock">Clock</span>,
|
||||
Play: () => <span data-testid="icon-play">Play</span>,
|
||||
Pause: () => <span data-testid="icon-pause">Pause</span>,
|
||||
Pencil: () => <span data-testid="icon-pencil">Edit</span>,
|
||||
Trash2: () => <span data-testid="icon-trash">Delete</span>,
|
||||
CheckCircle: () => <span data-testid="icon-check">Success</span>,
|
||||
XCircle: () => <span data-testid="icon-x">Failure</span>,
|
||||
ChevronDown: () => <span data-testid="icon-down">Down</span>,
|
||||
ChevronUp: () => <span data-testid="icon-up">Up</span>,
|
||||
Calendar: () => <span data-testid="icon-calendar">Calendar</span>,
|
||||
Webhook: () => <span data-testid="icon-webhook">Webhook</span>,
|
||||
Code: () => <span data-testid="icon-code">Code</span>,
|
||||
Zap: () => <span data-testid="icon-zap">Zap</span>,
|
||||
Globe: () => <span data-testid="icon-globe">Global</span>,
|
||||
Folder: () => <span data-testid="icon-folder">Project</span>,
|
||||
Layers: () => <span data-testid="icon-layers">Layers</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core (no runtime values needed — ScheduleForm inlines presets)
|
||||
vi.mock("@fusion/core", () => ({}));
|
||||
|
||||
// Mock the API module
|
||||
const mockFetchAutomations = vi.fn();
|
||||
const mockCreateAutomation = vi.fn();
|
||||
const mockUpdateAutomation = vi.fn();
|
||||
@@ -60,7 +58,6 @@ vi.mock("../../api", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({ value, onChange, disabled, models }: any) => (
|
||||
<select
|
||||
@@ -79,23 +76,6 @@ vi.mock("../CustomModelDropdown", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
return {
|
||||
id: "sched-1",
|
||||
name: "Test Schedule",
|
||||
description: "A test",
|
||||
scheduleType: "daily",
|
||||
cronExpression: "0 0 * * *",
|
||||
command: "echo hello",
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
return {
|
||||
id: "routine-001",
|
||||
@@ -123,691 +103,214 @@ describe("ScheduledTasksModal", () => {
|
||||
mockFetchRoutines.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders modal with title", async () => {
|
||||
it("renders the unified automations modal", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("has role=dialog and aria-labelledby", () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog.getAttribute("aria-labelledby")).toBe("schedules-modal-title");
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockFetchAutomations.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
expect(screen.getByText("Loading schedules…")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows empty state when no schedules", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
expect(screen.getByText("Automations")).toBeDefined();
|
||||
expect(screen.getByRole("dialog").getAttribute("aria-labelledby")).toBe("schedules-modal-title");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
expect(screen.getByText("No automations yet")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("Create your first schedule")).toBeDefined();
|
||||
expect(screen.getByText("Create your first automation")).toBeDefined();
|
||||
expect(screen.getByText("Routines")).toBeDefined();
|
||||
expect(mockFetchAutomations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows schedule cards when schedules exist", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "My Job" })]);
|
||||
it("shows routine cards and the new automation button when routines exist", async () => {
|
||||
mockFetchRoutines.mockResolvedValue([
|
||||
makeRoutine({ name: "Database Backup", command: "fn backup --create" }),
|
||||
]);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
expect(screen.getByText("Database Backup")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("fn backup --create")).toBeDefined();
|
||||
expect(screen.getByText("New Automation")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows New Schedule button when schedules exist", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
it("uses routine APIs with global scope by default", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
it("uses routine APIs with project scope when projectId is provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
|
||||
|
||||
it("calls onClose when overlay is clicked", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
const overlay = screen.getByRole("dialog").parentElement!;
|
||||
fireEvent.click(overlay);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose on Escape when in list view", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Scope behavior", () => {
|
||||
it("defaults to global scope when no projectId provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
// Verify fetchAutomations was called with global scope
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
|
||||
it("defaults to project scope when projectId is provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
// Verify fetchAutomations was called with project scope and projectId
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
|
||||
});
|
||||
|
||||
it("forwards projectId to fetchRoutines when projectId is provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
// Verify fetchRoutines was called with project scope and projectId
|
||||
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-456" });
|
||||
});
|
||||
});
|
||||
|
||||
it("can switch from global to project scope and reloads data", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
|
||||
|
||||
// Initial load with project scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
|
||||
});
|
||||
|
||||
// Clear mocks to track the reload after scope switch
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Click the global scope button
|
||||
const globalBtn = screen.getByRole("button", { name: /global/i });
|
||||
fireEvent.click(globalBtn);
|
||||
|
||||
// Should reload with global scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
it("reloads routines when switching scope", async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "Scoped Routine" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
|
||||
});
|
||||
mockFetchRoutines.mockClear();
|
||||
|
||||
it("switches from project to global scope and reloads data", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
// Initial load with global scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
|
||||
// Clear mocks to track the reload after scope switch
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Click the project scope button
|
||||
const projectBtn = screen.getByRole("button", { name: /project/i });
|
||||
fireEvent.click(projectBtn);
|
||||
|
||||
// Should reload with project scope (but no projectId available, so falls back to global)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project" });
|
||||
});
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /global/i }));
|
||||
|
||||
it("resets view to list when switching scope", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
|
||||
// Open create form
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Clear mocks
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Switch scope - should reset to list view
|
||||
const globalBtn = screen.getByRole("button", { name: /global/i });
|
||||
fireEvent.click(globalBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should be back to list view, not create form
|
||||
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
|
||||
expect(screen.getByText("Test Job")).toBeDefined();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("create flow", () => {
|
||||
it("shows create form when clicking New Schedule", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
it("opens the routine editor from the empty state", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first automation")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first automation"));
|
||||
|
||||
it("shows create form from empty state CTA button", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first schedule")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first schedule"));
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates a command automation and returns to the list", async () => {
|
||||
const created = makeRoutine({ name: "New Automation", command: "echo test" });
|
||||
mockFetchRoutines
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([created]);
|
||||
mockCreateRoutine.mockResolvedValue(created);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first automation")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first automation"));
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Automation" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo test" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
it("goes back to list on Escape from create form", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Should not close the modal, just go back to list
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates schedule and returns to list on success", async () => {
|
||||
const created = makeSchedule({ name: "New Job" });
|
||||
mockFetchAutomations
|
||||
.mockResolvedValueOnce([]) // initial load
|
||||
.mockResolvedValueOnce([created]); // after create
|
||||
mockCreateAutomation.mockResolvedValue(created);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first schedule")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first schedule"));
|
||||
|
||||
// Fill form
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Job" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo test" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Schedule created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("changing scope in create form updates modal's activeScope and reloads data", async () => {
|
||||
const schedule = makeSchedule({ name: "Test Job" });
|
||||
mockFetchAutomations
|
||||
.mockResolvedValueOnce([schedule]) // initial load with project scope
|
||||
.mockResolvedValueOnce([]); // reload with global scope after scope switch
|
||||
mockCreateAutomation.mockResolvedValue(schedule);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
// Initial load with project scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
|
||||
expect(screen.getByText("Test Job")).toBeDefined();
|
||||
});
|
||||
|
||||
// Open create form
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Clear mocks to track reload
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Click Global scope button in the form (note: icon chars in accessible name)
|
||||
const globalBtn = screen.getByRole("radio", { name: "🌍Global" });
|
||||
fireEvent.click(globalBtn);
|
||||
|
||||
// Modal should reload with global scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
|
||||
// Should be back in list view
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRoutine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "New Automation", command: "echo test" }),
|
||||
{ scope: "global" },
|
||||
);
|
||||
expect(addToast).toHaveBeenCalledWith("Routine created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("calls toggleAutomation and shows toast", async () => {
|
||||
const schedule = makeSchedule({ name: "My Job", enabled: true });
|
||||
mockFetchAutomations.mockResolvedValue([schedule]);
|
||||
mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false });
|
||||
it("edits routines through the unified interface", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine", command: "echo before" });
|
||||
const updated = { ...routine, name: "Updated Routine", command: "echo after" };
|
||||
mockFetchRoutines
|
||||
.mockResolvedValueOnce([routine])
|
||||
.mockResolvedValueOnce([updated]);
|
||||
mockUpdateRoutine.mockResolvedValue(updated);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Disable My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Job" disabled', "success");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Edit My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit Routine", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("forwards projectId when projectId is provided", async () => {
|
||||
const schedule = makeSchedule({ name: "My Job", enabled: true });
|
||||
mockFetchAutomations.mockResolvedValue([schedule]);
|
||||
mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo after" } });
|
||||
fireEvent.click(screen.getByText("Save Changes"));
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Disable My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "project", projectId: "proj-123" });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRoutine).toHaveBeenCalledWith(
|
||||
"routine-001",
|
||||
expect.objectContaining({ name: "Updated Routine", command: "echo after" }),
|
||||
{ scope: "global" },
|
||||
);
|
||||
expect(addToast).toHaveBeenCalledWith("Routine updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("calls deleteAutomation after confirm", async () => {
|
||||
const schedule = makeSchedule({ name: "My Job" });
|
||||
mockFetchAutomations.mockResolvedValue([schedule]);
|
||||
mockDeleteAutomation.mockResolvedValue(schedule);
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Delete My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('Deleted "My Job"', "success");
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("manual run", () => {
|
||||
it("calls runAutomation and shows success toast", async () => {
|
||||
const schedule = makeSchedule({ name: "My Job" });
|
||||
mockFetchAutomations.mockResolvedValue([schedule]);
|
||||
const result: AutomationRunResult = {
|
||||
success: true,
|
||||
output: "ok",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
completedAt: "2026-01-01T00:00:01.000Z",
|
||||
};
|
||||
mockRunAutomation.mockResolvedValue({ schedule, result });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Run My Job now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRunAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Job" completed successfully', "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when run fails", async () => {
|
||||
const schedule = makeSchedule({ name: "My Job" });
|
||||
mockFetchAutomations.mockResolvedValue([schedule]);
|
||||
const result: AutomationRunResult = {
|
||||
success: false,
|
||||
output: "",
|
||||
error: "Command not found",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
completedAt: "2026-01-01T00:00:01.000Z",
|
||||
};
|
||||
mockRunAutomation.mockResolvedValue({ schedule, result });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Run My Job now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Command not found"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("shows error toast when loading fails", async () => {
|
||||
mockFetchAutomations.mockRejectedValue(new Error("Network error"));
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Routine Tab Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("Tab navigation", () => {
|
||||
it("shows both Schedules and Routines tabs", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Schedules")).toBeDefined();
|
||||
expect(screen.getByText("Routines")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to Schedules tab", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Routines tab switches to routines view", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Schedules")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Schedules tab switches back to schedules view", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
// Switch to Routines
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
});
|
||||
// Switch back to Schedules
|
||||
fireEvent.click(screen.getByText("Schedules"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("switching tabs resets sub-views", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
|
||||
// Open create form
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
|
||||
|
||||
// Switch to Routines tab
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
});
|
||||
|
||||
// Switch back to Schedules - should be in list view, not create
|
||||
fireEvent.click(screen.getByText("Schedules"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
|
||||
expect(screen.getByText("New Schedule")).toBeDefined(); // The button
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines list", () => {
|
||||
it("shows empty state when no routines exist", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No routines yet")).toBeDefined();
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows routine cards when routines exist", async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "My Routine" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "New Routine" button when routines exist', async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines create flow", () => {
|
||||
it('shows RoutineEditor when clicking "New Routine"', async () => {
|
||||
mockFetchRoutines.mockResolvedValue([makeRoutine()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("New Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows RoutineEditor from empty state CTA", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to list on Escape from create form", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first routine"));
|
||||
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Should not close the modal, just go back to list
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates routine and returns to list on success", async () => {
|
||||
const created = makeRoutine({ name: "New Routine" });
|
||||
mockFetchRoutines
|
||||
.mockResolvedValueOnce([]) // initial load
|
||||
.mockResolvedValueOnce([created]); // after create
|
||||
mockCreateRoutine.mockResolvedValue(created);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first routine"));
|
||||
|
||||
// Fill form
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
|
||||
fireEvent.click(screen.getByText("Create Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Routine created", "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines edit flow", () => {
|
||||
it("shows RoutineEditor with pre-filled data when editing", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Edit My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit Routine", { selector: "h4" })).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates routine and returns to list on success", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
const updated = { ...routine, name: "Updated Routine" };
|
||||
mockFetchRoutines
|
||||
.mockResolvedValueOnce([routine]) // initial load
|
||||
.mockResolvedValueOnce([updated]); // after update
|
||||
mockUpdateRoutine.mockResolvedValue(updated);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Edit My Routine"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } });
|
||||
fireEvent.click(screen.getByText("Save Changes"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Routine updated", "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines run", () => {
|
||||
it("calls runRoutine and shows success toast", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
const result = {
|
||||
it("runs routines and shows success or failure toasts", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockRunRoutine.mockResolvedValue({
|
||||
result: {
|
||||
routineId: routine.id,
|
||||
success: true,
|
||||
output: "Done",
|
||||
startedAt: "2026-04-08T00:00:00.000Z",
|
||||
completedAt: "2026-04-08T00:01:00.000Z",
|
||||
};
|
||||
mockRunRoutine.mockResolvedValue({ result });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Run My Routine now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
it("shows error toast when run fails", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
const result = {
|
||||
routineId: routine.id,
|
||||
success: false,
|
||||
error: "Failed",
|
||||
startedAt: "2026-04-08T00:00:00.000Z",
|
||||
completedAt: "2026-04-08T00:01:00.000Z",
|
||||
};
|
||||
mockRunRoutine.mockResolvedValue({ result });
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Run My Routine now"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Run My Routine now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines delete", () => {
|
||||
it("calls deleteRoutine after confirm dialog", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockDeleteRoutine.mockResolvedValue(undefined);
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
it("deletes routines after confirmation", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine" });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockDeleteRoutine.mockResolvedValue(undefined);
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete My Routine"));
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete My Routine"));
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
|
||||
});
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("toggles routines through updateRoutine", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine", enabled: true });
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockUpdateRoutine.mockResolvedValue({ ...routine, enabled: false });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Disable My Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }, { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routines toggle", () => {
|
||||
it("calls updateRoutine with flipped enabled state", async () => {
|
||||
const routine = makeRoutine({ name: "My Routine", enabled: true });
|
||||
const updated = { ...routine, enabled: false };
|
||||
mockFetchRoutines.mockResolvedValue([routine]);
|
||||
mockUpdateRoutine.mockResolvedValue(updated);
|
||||
it("backs out of editor on Escape and closes from list on Escape", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByText("Routines"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Routine")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Disable My Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }, { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create your first automation")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create your first automation"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No automations yet")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user