feat(FN-1275): add Routines tab to ScheduledTasksModal

- Add RoutineCard component with status display and run history
- Add RoutineEditor form with trigger, agent, policy, and model fields
- Integrate Routines tab into ScheduledTasksModal alongside Scheduled Tasks
- Add CSS styles for routine components
- Add comprehensive tests for RoutineCard, RoutineEditor, and ScheduledTasksModal
This commit is contained in:
gsxdsm
2026-04-10 03:07:25 -07:00
parent e028a57bea
commit d1e580e577
7 changed files with 2190 additions and 19 deletions

View File

@@ -0,0 +1,286 @@
import { useState, useCallback } from "react";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap } from "lucide-react";
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
/**
* Format a duration in milliseconds to a human-readable string.
*/
function formatDurationMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
/**
* Format an ISO timestamp to a relative time string.
*/
function relativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
// Future
if (diffMs < 0) {
const absDiff = Math.abs(diffMs);
if (absDiff < 60_000) return "in a moment";
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
return `in ${Math.floor(absDiff / 86_400_000)}d`;
}
// Past
if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
return `${Math.floor(diffMs / 86_400_000)}d ago`;
}
const TRIGGER_TYPE_COLORS: Record<RoutineTriggerType, string> = {
cron: "var(--color-blue, #3b82f6)",
webhook: "var(--color-purple, #a855f7)",
api: "var(--color-green, #22c55e)",
manual: "var(--color-gray, #6b7280)",
};
const TRIGGER_TYPE_LABELS: Record<RoutineTriggerType, string> = {
cron: "Cron",
webhook: "Webhook",
api: "API",
manual: "Manual",
};
const TRIGGER_TYPE_ICONS: Record<RoutineTriggerType, React.ComponentType<{ size: number }>> = {
cron: Calendar,
webhook: Webhook,
api: Code,
manual: Zap,
};
const EXECUTION_POLICY_LABELS: Record<RoutineExecutionPolicy, string> = {
parallel: "Concurrent",
queue: "Queued",
reject: "Exclusive",
};
const CATCH_UP_POLICY_LABELS: Record<RoutineCatchUpPolicy, string> = {
run: "Catch up",
skip: "Skip missed",
run_one: "Catch up (latest)",
};
interface RoutineCardProps {
routine: Routine;
onEdit: (routine: Routine) => void;
onDelete: (routine: Routine) => void;
onRun: (routine: Routine) => void;
onToggle: (routine: Routine) => void;
/** Whether a manual run is currently in progress. */
running?: boolean;
}
function RunResultBadge({ result }: { result: RoutineExecutionResult }) {
const duration = result.completedAt && result.startedAt
? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime()
: 0;
return (
<span className={`schedule-run-badge ${result.success ? "success" : "failure"}`}>
{result.success ? (
<CheckCircle size={12} />
) : (
<XCircle size={12} />
)}
<span>{result.success ? "Success" : "Failed"}</span>
{duration > 0 && (
<span className="schedule-run-duration">{formatDurationMs(duration)}</span>
)}
</span>
);
}
function RunHistoryItem({ result, index }: { result: RoutineExecutionResult; index: number }) {
const [expanded, setExpanded] = useState(false);
const duration = result.completedAt && result.startedAt
? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime()
: 0;
return (
<div className="schedule-history-item">
<button
className="schedule-history-header"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-label={`Run #${index + 1}: ${result.success ? "succeeded" : "failed"} ${relativeTime(result.startedAt)}`}
>
<span className={`schedule-history-status ${result.success ? "success" : "failure"}`}>
{result.success ? <CheckCircle size={12} /> : <XCircle size={12} />}
</span>
<span className="schedule-history-time">{relativeTime(result.startedAt)}</span>
{result.triggerType && (
<span className="routine-history-trigger-type">{TRIGGER_TYPE_LABELS[result.triggerType]}</span>
)}
<span className="schedule-history-duration">{formatDurationMs(duration)}</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<div className="schedule-history-detail">
{result.output && (
<pre className="schedule-history-output">{result.output}</pre>
)}
{result.error && (
<div className="schedule-history-error">{result.error}</div>
)}
</div>
)}
</div>
);
}
export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running }: RoutineCardProps) {
const [showHistory, setShowHistory] = useState(false);
const handleDelete = useCallback(() => {
if (window.confirm(`Delete routine "${routine.name}"? This cannot be undone.`)) {
onDelete(routine);
}
}, [routine, onDelete]);
const triggerColor = TRIGGER_TYPE_COLORS[routine.trigger.type];
const TriggerIcon = TRIGGER_TYPE_ICONS[routine.trigger.type];
// Get cron expression if available (from trigger or direct field)
const cronExpression = routine.trigger.type === "cron"
? (routine.trigger as any).cronExpression || routine.cronExpression || ""
: routine.cronExpression || "";
return (
<div className={`routine-card${routine.enabled ? "" : " disabled"}`}>
<div className="routine-card-header">
<div className="routine-card-info">
<div className="routine-card-name-row">
<span className="routine-card-name">{routine.name}</span>
<span
className="routine-trigger-badge"
style={{ borderColor: triggerColor, color: triggerColor }}
>
<TriggerIcon size={10} />
{TRIGGER_TYPE_LABELS[routine.trigger.type]}
</span>
</div>
{routine.description && (
<p className="routine-card-description">{routine.description}</p>
)}
</div>
<div className="routine-card-actions">
<button
className="btn-icon"
onClick={() => onRun(routine)}
disabled={running}
title={running ? "Running…" : "Run now"}
aria-label={running ? "Running…" : `Run ${routine.name} now`}
>
<Play size={14} />
</button>
<button
className="btn-icon"
onClick={() => onToggle(routine)}
title={routine.enabled ? "Disable" : "Enable"}
aria-label={routine.enabled ? `Disable ${routine.name}` : `Enable ${routine.name}`}
aria-pressed={routine.enabled}
>
{routine.enabled ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
className="btn-icon"
onClick={() => onEdit(routine)}
title="Edit"
aria-label={`Edit ${routine.name}`}
>
<Pencil size={14} />
</button>
<button
className="btn-icon"
onClick={handleDelete}
title="Delete"
aria-label={`Delete ${routine.name}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="routine-card-meta">
{/* Cron expression for cron triggers */}
{routine.trigger.type === "cron" && cronExpression && (
<div className="routine-meta-item">
<Clock size={12} />
<code className="routine-cron">{cronExpression}</code>
</div>
)}
{/* Policy badges */}
<div className="routine-meta-item">
<span className="routine-policy-badge" title={`Execution policy: ${routine.executionPolicy}`}>
{EXECUTION_POLICY_LABELS[routine.executionPolicy]}
</span>
</div>
<div className="routine-meta-item">
<span className="routine-policy-badge" title={`Catch-up policy: ${routine.catchUpPolicy}`}>
{CATCH_UP_POLICY_LABELS[routine.catchUpPolicy]}
</span>
</div>
{/* Timing info */}
{routine.nextRunAt && routine.enabled && (
<div className="routine-meta-item">
<span className="routine-meta-label">Next:</span>
<span title={routine.nextRunAt}>{relativeTime(routine.nextRunAt)}</span>
</div>
)}
{routine.lastRunAt && (
<div className="routine-meta-item">
<span className="routine-meta-label">Last:</span>
<span title={routine.lastRunAt}>{relativeTime(routine.lastRunAt)}</span>
</div>
)}
{routine.lastRunResult && (
<RunResultBadge result={routine.lastRunResult} />
)}
<div className="routine-meta-item">
<span className="routine-meta-label">Runs:</span>
<span>{routine.runCount}</span>
</div>
</div>
{routine.runHistory.length > 0 && (
<div className="routine-card-history">
<button
className="schedule-history-toggle"
onClick={() => setShowHistory((h) => !h)}
aria-expanded={showHistory}
>
{showHistory ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<span>Run History ({routine.runHistory.length})</span>
</button>
{showHistory && (
<div className="schedule-history-list">
{routine.runHistory.slice(0, 10).map((result, i) => (
<RunHistoryItem key={`${result.startedAt}-${i}`} result={result} index={i} />
))}
{routine.runHistory.length > 10 && (
<div className="schedule-history-more">
and {routine.runHistory.length - 10} more
</div>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,433 @@
import { useState, useCallback } from "react";
import { Calendar, Webhook, Code, Zap } from "lucide-react";
import type {
Routine,
RoutineCreateInput,
RoutineUpdateInput,
RoutineTrigger,
RoutineTriggerType,
RoutineCronTrigger,
RoutineWebhookTrigger,
RoutineApiTrigger,
RoutineManualTrigger,
RoutineCatchUpPolicy,
RoutineExecutionPolicy,
} from "@fusion/core";
/**
* 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));
}
/**
* Build a trigger object from form state.
*/
function buildTrigger(
triggerType: RoutineTriggerType,
cronExpression: string,
webhookPath: string,
webhookSecret: string,
endpoint: string,
): RoutineTrigger {
switch (triggerType) {
case "cron":
return { type: "cron", cronExpression } as RoutineCronTrigger;
case "webhook":
return {
type: "webhook",
webhookPath: webhookPath || "/trigger/" + Math.random().toString(36).slice(2, 10),
secret: webhookSecret || undefined,
} as RoutineWebhookTrigger;
case "api":
return {
type: "api",
endpoint: endpoint || "/api/routine/" + Math.random().toString(36).slice(2, 10),
} as RoutineApiTrigger;
case "manual":
return { type: "manual" } as RoutineManualTrigger;
}
}
/**
* Extract trigger fields from a Routine object.
*/
function extractTriggerFields(routine: Routine) {
const trigger = routine.trigger;
switch (trigger.type) {
case "cron":
return {
triggerType: "cron" as RoutineTriggerType,
cronExpression: (trigger as RoutineCronTrigger).cronExpression,
webhookPath: "",
webhookSecret: (trigger as RoutineWebhookTrigger).secret || "",
endpoint: "",
};
case "webhook":
return {
triggerType: "webhook" as RoutineTriggerType,
cronExpression: "",
webhookPath: (trigger as RoutineWebhookTrigger).webhookPath,
webhookSecret: (trigger as RoutineWebhookTrigger).secret || "",
endpoint: "",
};
case "api":
return {
triggerType: "api" as RoutineTriggerType,
cronExpression: "",
webhookPath: "",
webhookSecret: "",
endpoint: (trigger as RoutineApiTrigger).endpoint,
};
case "manual":
return {
triggerType: "manual" as RoutineTriggerType,
cronExpression: "",
webhookPath: "",
webhookSecret: "",
endpoint: "",
};
}
}
const TRIGGER_TYPE_LABELS: Record<RoutineTriggerType, string> = {
cron: "Cron Schedule",
webhook: "Webhook",
api: "API",
manual: "Manual",
};
const EXECUTION_POLICY_OPTIONS: { value: RoutineExecutionPolicy; label: string }[] = [
{ value: "parallel", label: "Allow concurrent runs" },
{ value: "queue", label: "Queue after current (one at a time)" },
{ value: "reject", label: "Reject new runs while running" },
];
const CATCH_UP_POLICY_OPTIONS: { value: RoutineCatchUpPolicy; label: string }[] = [
{ value: "skip", label: "Skip missed runs" },
{ value: "run_one", label: "Run the most recent missed run" },
{ value: "run", label: "Run all missed runs" },
];
interface RoutineEditorProps {
/** Existing routine for editing. Omit for create mode. */
routine?: Routine;
/** Called with form data on submit. */
onSubmit: (input: RoutineCreateInput) => Promise<void>;
/** Called when the user cancels. */
onCancel: () => void;
}
export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProps) {
const isEditing = !!routine;
// Extract trigger fields if editing
const initialTriggerFields = routine ? extractTriggerFields(routine) : {
triggerType: "cron" as RoutineTriggerType,
cronExpression: "0 * * * *",
webhookPath: "",
webhookSecret: "",
endpoint: "",
};
const [name, setName] = useState(routine?.name ?? "");
const [description, setDescription] = useState(routine?.description ?? "");
const [triggerType, setTriggerType] = useState<RoutineTriggerType>(initialTriggerFields.triggerType);
const [cronExpression, setCronExpression] = useState(initialTriggerFields.cronExpression);
const [webhookPath, setWebhookPath] = useState(initialTriggerFields.webhookPath);
const [webhookSecret, setWebhookSecret] = useState(initialTriggerFields.webhookSecret);
const [endpoint, setEndpoint] = useState(initialTriggerFields.endpoint);
const [executionPolicy, setExecutionPolicy] = useState<RoutineExecutionPolicy>(
routine?.executionPolicy ?? "queue"
);
const [catchUpPolicy, setCatchUpPolicy] = useState<RoutineCatchUpPolicy>(
routine?.catchUpPolicy ?? "run_one"
);
const [enabled, setEnabled] = useState(routine?.enabled ?? true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const validate = useCallback((): boolean => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = "Name is required";
if (triggerType === "cron") {
if (!cronExpression.trim()) {
e.cronExpression = "Cron expression is required";
} else if (!isLikelyCron(cronExpression)) {
e.cronExpression = "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')";
}
}
if (triggerType === "webhook" && !webhookPath.trim()) {
e.webhookPath = "Webhook path is required";
}
if (triggerType === "api" && !endpoint.trim()) {
e.endpoint = "API endpoint is required";
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, triggerType, cronExpression, webhookPath, endpoint]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
try {
const trigger = buildTrigger(triggerType, cronExpression, webhookPath, webhookSecret, endpoint);
const input: RoutineCreateInput = {
name: name.trim(),
description: description.trim() || undefined,
trigger,
executionPolicy,
catchUpPolicy,
enabled,
};
await onSubmit(input);
} finally {
setSubmitting(false);
}
},
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, executionPolicy, catchUpPolicy, enabled],
);
const nameErrorId = "routine-name-error";
const cronErrorId = "routine-cron-error";
const webhookErrorId = "routine-webhook-error";
const endpointErrorId = "routine-endpoint-error";
return (
<form className="routine-form" onSubmit={handleSubmit} noValidate>
<h4 className="settings-section-heading">
{isEditing ? "Edit Routine" : "New Routine"}
</h4>
{/* Basic Info */}
<div className="form-group">
<label htmlFor="routine-name">Name</label>
<input
id="routine-name"
type="text"
placeholder="e.g. Daily standup reminder"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? nameErrorId : undefined}
/>
{errors.name && (
<small id={nameErrorId} className="field-error">{errors.name}</small>
)}
</div>
<div className="form-group">
<label htmlFor="routine-description">Description (optional)</label>
<textarea
id="routine-description"
placeholder="What does this routine do?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
{/* Trigger Type */}
<div className="form-group">
<label>Trigger Type</label>
<div className="routine-trigger-type-selector" role="radiogroup" aria-label="Trigger type">
<button
type="button"
className={`routine-trigger-btn${triggerType === "cron" ? " active" : ""}`}
onClick={() => setTriggerType("cron")}
role="radio"
aria-checked={triggerType === "cron"}
>
<Calendar size={14} />
Cron
</button>
<button
type="button"
className={`routine-trigger-btn${triggerType === "webhook" ? " active" : ""}`}
onClick={() => setTriggerType("webhook")}
role="radio"
aria-checked={triggerType === "webhook"}
>
<Webhook size={14} />
Webhook
</button>
<button
type="button"
className={`routine-trigger-btn${triggerType === "api" ? " active" : ""}`}
onClick={() => setTriggerType("api")}
role="radio"
aria-checked={triggerType === "api"}
>
<Code size={14} />
API
</button>
<button
type="button"
className={`routine-trigger-btn${triggerType === "manual" ? " active" : ""}`}
onClick={() => setTriggerType("manual")}
role="radio"
aria-checked={triggerType === "manual"}
>
<Zap size={14} />
Manual
</button>
</div>
</div>
{/* Cron Expression */}
{triggerType === "cron" && (
<div className="form-group">
<label htmlFor="routine-cron">Cron Expression</label>
<input
id="routine-cron"
type="text"
placeholder="* * * * *"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
aria-invalid={!!errors.cronExpression}
aria-describedby={errors.cronExpression ? cronErrorId : undefined}
/>
{errors.cronExpression ? (
<small id={cronErrorId} className="field-error">{errors.cronExpression}</small>
) : (
<small>min hour day month weekday <a href="https://crontab.guru" target="_blank" rel="noopener noreferrer">crontab.guru</a></small>
)}
</div>
)}
{/* Webhook Configuration */}
{triggerType === "webhook" && (
<>
<div className="form-group">
<label htmlFor="routine-webhook-path">Webhook Path</label>
<input
id="routine-webhook-path"
type="text"
placeholder="/trigger/my-routine"
value={webhookPath}
onChange={(e) => setWebhookPath(e.target.value)}
aria-invalid={!!errors.webhookPath}
aria-describedby={errors.webhookPath ? webhookErrorId : undefined}
/>
{errors.webhookPath ? (
<small id={webhookErrorId} className="field-error">{errors.webhookPath}</small>
) : (
<small>URL path for the webhook endpoint</small>
)}
</div>
<div className="form-group">
<label htmlFor="routine-webhook-secret">Webhook Secret (optional)</label>
<input
id="routine-webhook-secret"
type="password"
placeholder="Optional — leave empty for unauthenticated webhooks"
value={webhookSecret}
onChange={(e) => setWebhookSecret(e.target.value)}
/>
<small>HMAC secret for signature verification. Leave empty for unauthenticated webhooks.</small>
</div>
</>
)}
{/* API Configuration */}
{triggerType === "api" && (
<div className="form-group">
<label htmlFor="routine-endpoint">API Endpoint</label>
<input
id="routine-endpoint"
type="text"
placeholder="/api/routine/my-routine"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
aria-invalid={!!errors.endpoint}
aria-describedby={errors.endpoint ? endpointErrorId : undefined}
/>
{errors.endpoint ? (
<small id={endpointErrorId} className="field-error">{errors.endpoint}</small>
) : (
<small>API endpoint path that triggers this routine</small>
)}
</div>
)}
{/* Manual trigger info */}
{triggerType === "manual" && (
<div className="form-group">
<small className="routine-trigger-info">
This routine will be triggered manually via the dashboard or API.
</small>
</div>
)}
{/* Execution Policy */}
<div className="form-group">
<label htmlFor="routine-execution-policy">Execution Policy</label>
<select
id="routine-execution-policy"
value={executionPolicy}
onChange={(e) => setExecutionPolicy(e.target.value as RoutineExecutionPolicy)}
>
{EXECUTION_POLICY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<small>How to handle concurrent executions of this routine</small>
</div>
{/* Catch-up Policy */}
<div className="form-group">
<label htmlFor="routine-catchup-policy">Catch-up Policy</label>
<select
id="routine-catchup-policy"
value={catchUpPolicy}
onChange={(e) => setCatchUpPolicy(e.target.value as RoutineCatchUpPolicy)}
>
{CATCH_UP_POLICY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<small>What to do when a scheduled run is missed</small>
</div>
{/* Enabled */}
<div className="form-group">
<label htmlFor="routine-enabled" className="checkbox-label">
<input
id="routine-enabled"
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enabled
</label>
<small>When disabled, the routine will not run automatically</small>
</div>
<div className="modal-actions">
<button
type="button"
className="btn btn-sm"
onClick={onCancel}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={submitting}
>
{submitting ? "Saving…" : isEditing ? "Save Changes" : "Create Routine"}
</button>
</div>
</form>
);
}

View File

@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import { Plus, Clock } from "lucide-react";
import type { ScheduledTask, ScheduledTaskCreateInput } from "@fusion/core";
import { Plus, Clock, Zap } from "lucide-react";
import type {
ScheduledTask,
ScheduledTaskCreateInput,
Routine,
RoutineCreateInput,
} from "@fusion/core";
import {
fetchAutomations,
createAutomation,
@@ -8,12 +13,19 @@ import {
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";
/** Polling interval for auto-refreshing the schedule list (30 seconds). */
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
const POLL_INTERVAL_MS = 30_000;
interface ScheduledTasksModalProps {
@@ -22,8 +34,13 @@ interface ScheduledTasksModalProps {
}
type ModalView = "list" | "create" | "edit";
type ActiveTab = "schedules" | "routines";
export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalProps) {
// Tab state
const [activeTab, setActiveTab] = useState<ActiveTab>("schedules");
// Schedule state
const [schedules, setSchedules] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<ModalView>("list");
@@ -31,6 +48,12 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
/** 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");
const [editingRoutine, setEditingRoutine] = useState<Routine | undefined>();
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
// Load schedules
const loadSchedules = useCallback(async () => {
try {
@@ -43,31 +66,55 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
}
}, [addToast]);
// Load routines
const loadRoutines = useCallback(async () => {
try {
const data = await fetchRoutines();
setRoutines(data);
} catch (err: any) {
addToast(err.message || "Failed to load routines", "error");
}
}, [addToast]);
useEffect(() => {
loadSchedules();
}, [loadSchedules]);
loadRoutines();
}, [loadSchedules, loadRoutines]);
// Poll for updates while modal is open
useEffect(() => {
const interval = setInterval(loadSchedules, POLL_INTERVAL_MS);
const interval = setInterval(() => {
void loadSchedules();
void loadRoutines();
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [loadSchedules]);
}, [loadSchedules, loadRoutines]);
// Close on Escape (only when not in a sub-form)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (view !== "list") {
setView("list");
setEditingSchedule(undefined);
if (activeTab === "schedules") {
if (view !== "list") {
setView("list");
setEditingSchedule(undefined);
} else {
onClose();
}
} else {
onClose();
// Routines tab
if (routineView !== "list") {
setRoutineView("list");
setEditingRoutine(undefined);
} else {
onClose();
}
}
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, view]);
}, [onClose, activeTab, view, routineView]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
@@ -76,7 +123,8 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
[onClose],
);
// CRUD handlers
// ── Schedule CRUD handlers ──────────────────────────────────────────────
const handleCreate = useCallback(
async (input: ScheduledTaskCreateInput) => {
try {
@@ -166,7 +214,110 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
setEditingSchedule(undefined);
}, []);
const renderContent = () => {
// ── Routine CRUD handlers ───────────────────────────────────────────────
const handleCreateRoutine = useCallback(
async (input: RoutineCreateInput) => {
try {
await createRoutine(input);
addToast("Routine created", "success");
setRoutineView("list");
await loadRoutines();
} catch (err: any) {
addToast(err.message || "Failed to create routine", "error");
}
},
[addToast, loadRoutines],
);
const handleEditRoutine = useCallback((routine: Routine) => {
setEditingRoutine(routine);
setRoutineView("edit");
}, []);
const handleUpdateRoutine = useCallback(
async (input: RoutineCreateInput) => {
if (!editingRoutine) return;
try {
await updateRoutine(editingRoutine.id, input);
addToast("Routine updated", "success");
setRoutineView("list");
setEditingRoutine(undefined);
await loadRoutines();
} catch (err: any) {
addToast(err.message || "Failed to update routine", "error");
}
},
[editingRoutine, addToast, loadRoutines],
);
const handleDeleteRoutine = useCallback(
async (routine: Routine) => {
try {
await deleteRoutine(routine.id);
addToast(`Deleted "${routine.name}"`, "success");
await loadRoutines();
} catch (err: any) {
addToast(err.message || "Failed to delete routine", "error");
}
},
[addToast, loadRoutines],
);
const handleRunRoutine = useCallback(
async (routine: Routine) => {
setRunningRoutineId(routine.id);
try {
const { result } = await runRoutine(routine.id);
if (result.success) {
addToast(`"${routine.name}" completed successfully`, "success");
} else {
addToast(`"${routine.name}" failed: ${result.error || "Unknown error"}`, "error");
}
await loadRoutines();
} catch (err: any) {
addToast(err.message || "Failed to run routine", "error");
} finally {
setRunningRoutineId(null);
}
},
[addToast, loadRoutines],
);
const handleToggleRoutine = useCallback(
async (routine: Routine) => {
try {
await updateRoutine(routine.id, { enabled: !routine.enabled });
addToast(
`"${routine.name}" ${routine.enabled ? "disabled" : "enabled"}`,
"success",
);
await loadRoutines();
} catch (err: any) {
addToast(err.message || "Failed to toggle routine", "error");
}
},
[addToast, loadRoutines],
);
const handleRoutineCancel = useCallback(() => {
setRoutineView("list");
setEditingRoutine(undefined);
}, []);
// ── Tab switch handlers ─────────────────────────────────────────────────
const handleTabSwitch = useCallback((tab: ActiveTab) => {
setActiveTab(tab);
setView("list");
setEditingSchedule(undefined);
setRoutineView("list");
setEditingRoutine(undefined);
}, []);
// ── Render content ─────────────────────────────────────────────────────
const renderSchedulesContent = () => {
if (view === "create") {
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} />;
}
@@ -220,20 +371,89 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
);
};
const renderRoutinesContent = () => {
if (routineView === "create") {
return <RoutineEditor onSubmit={handleCreateRoutine} onCancel={handleRoutineCancel} />;
}
if (routineView === "edit" && editingRoutine) {
return (
<RoutineEditor
routine={editingRoutine}
onSubmit={handleUpdateRoutine}
onCancel={handleRoutineCancel}
/>
);
}
// List view
if (routines.length === 0) {
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>
<button
className="btn btn-primary btn-sm"
onClick={() => setRoutineView("create")}
>
<Plus size={14} />
Create your first routine
</button>
</div>
);
}
return (
<div className="routine-list">
{routines.map((r) => (
<RoutineCard
key={r.id}
routine={r}
onEdit={handleEditRoutine}
onDelete={handleDeleteRoutine}
onRun={handleRunRoutine}
onToggle={handleToggleRoutine}
running={runningRoutineId === r.id}
/>
))}
</div>
);
};
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;
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal modal-lg" role="dialog" aria-labelledby="schedules-modal-title">
<div className="modal-header">
<h3 id="schedules-modal-title">Scheduled Tasks</h3>
<div className="modal-header-actions">
{view === "list" && schedules.length > 0 && (
{isShowingList && (
<button
className="btn btn-primary btn-sm"
onClick={() => setView("create")}
aria-label="Create new schedule"
onClick={() => {
if (activeTab === "schedules") {
setView("create");
} else {
setRoutineView("create");
}
}}
aria-label={activeTab === "schedules" ? "Create new schedule" : "Create new routine"}
>
<Plus size={14} />
New Schedule
{activeTab === "schedules" ? "New Schedule" : "New Routine"}
</button>
)}
<button className="modal-close" onClick={onClose} aria-label="Close">
@@ -242,7 +462,31 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
</div>
</div>
<div className="schedule-modal-content">
{/* Tab navigation */}
<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" : ""}`}
role="tab"
id="tab-routines"
aria-selected={activeTab === "routines"}
aria-controls="scheduled-tasks-content"
onClick={() => handleTabSwitch("routines")}
>
<Zap size={14} /> Routines
</button>
</div>
<div className="schedule-modal-content" role="tabpanel" id="scheduled-tasks-content">
{renderContent()}
</div>
</div>

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RoutineCard } from "../RoutineCard";
import type { Routine, RoutineExecutionResult, RoutineTriggerType } from "@fusion/core";
// Mock lucide-react
vi.mock("lucide-react", () => ({
Plus: () => <span data-testid="icon-plus">+</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>,
Clock: () => <span data-testid="icon-clock">🕐</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>,
}));
// Mock @fusion/core (no runtime values needed)
vi.mock("@fusion/core", () => ({}));
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "routine-001",
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
executionPolicy: "parallel",
catchUpPolicy: "skip",
enabled: true,
runCount: 3,
runHistory: [],
createdAt: "2026-04-07T00:00:00.000Z",
updatedAt: "2026-04-07T00:00:00.000Z",
...overrides,
};
}
function makeRunResult(overrides: Partial<RoutineExecutionResult> = {}): RoutineExecutionResult {
return {
routineId: "routine-001",
success: true,
output: "Done",
startedAt: "2026-04-08T00:00:00.000Z",
completedAt: "2026-04-08T00:01:00.000Z",
...overrides,
};
}
describe("RoutineCard", () => {
const onEdit = vi.fn();
const onDelete = vi.fn();
const onRun = vi.fn();
const onToggle = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
// ── Rendering ─────────────────────────────────────────────────────────
describe("Rendering", () => {
it("renders routine name and trigger type badge (cron)", () => {
render(<RoutineCard routine={makeRoutine()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Test Routine")).toBeDefined();
expect(screen.getByText("Cron")).toBeDefined();
});
it("renders cron expression for cron triggers", () => {
render(<RoutineCard routine={makeRoutine()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("0 * * * *")).toBeDefined();
});
it("renders Webhook label for webhook triggers (no cron expression shown)", () => {
const routine = makeRoutine({ trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "secret123" } });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Webhook")).toBeDefined();
expect(screen.queryByText("0 * * * *")).toBeNull();
});
it("renders API label for api triggers", () => {
const routine = makeRoutine({ trigger: { type: "api", endpoint: "/api/test" } });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("API")).toBeDefined();
});
it("renders Manual label for manual triggers", () => {
const routine = makeRoutine({ trigger: { type: "manual" } });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Manual")).toBeDefined();
});
it("renders execution policy badge (parallel)", () => {
render(<RoutineCard routine={makeRoutine({ executionPolicy: "parallel" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Concurrent")).toBeDefined();
});
it("renders execution policy badge (queue)", () => {
render(<RoutineCard routine={makeRoutine({ executionPolicy: "queue" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Queued")).toBeDefined();
});
it("renders catch-up policy badge (skip)", () => {
render(<RoutineCard routine={makeRoutine({ catchUpPolicy: "skip" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Skip missed")).toBeDefined();
});
it("renders catch-up policy badge (run_one)", () => {
render(<RoutineCard routine={makeRoutine({ catchUpPolicy: "run_one" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Catch up (latest)")).toBeDefined();
});
it("renders next run time", () => {
const futureDate = new Date(Date.now() + 3600000).toISOString();
render(<RoutineCard routine={makeRoutine({ nextRunAt: futureDate })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText(/Next:/)).toBeDefined();
});
it("renders last run time", () => {
render(<RoutineCard routine={makeRoutine({ lastRunAt: "2026-04-08T00:00:00.000Z" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText(/Last:/)).toBeDefined();
});
it("renders run count", () => {
render(<RoutineCard routine={makeRoutine({ runCount: 5 })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Runs:")).toBeDefined();
expect(screen.getByText("5")).toBeDefined();
});
it("shows disabled styling when routine.enabled === false", () => {
const { container } = render(<RoutineCard routine={makeRoutine({ enabled: false })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(container.querySelector(".routine-card.disabled")).toBeDefined();
});
it("shows last run result badge when lastRunResult is present (success)", () => {
const routine = makeRoutine({ lastRunResult: makeRunResult({ success: true }) });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Success")).toBeDefined();
});
it("shows last run result badge when lastRunResult is present (failure)", () => {
const routine = makeRoutine({ lastRunResult: makeRunResult({ success: false, error: "Failed" }) });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Failed")).toBeDefined();
});
it("shows description when present", () => {
render(<RoutineCard routine={makeRoutine({ description: "My description" })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("My description")).toBeDefined();
});
it("hides description when not present", () => {
render(<RoutineCard routine={makeRoutine({ description: undefined })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.queryByText("A test routine")).toBeNull();
});
});
// ── Actions ───────────────────────────────────────────────────────────
describe("Actions", () => {
it("clicking run button calls onRun with the routine", () => {
const routine = makeRoutine();
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
fireEvent.click(screen.getByLabelText("Run Test Routine now"));
expect(onRun).toHaveBeenCalledWith(routine);
});
it("disables run button when running prop is true", () => {
render(<RoutineCard routine={makeRoutine()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} running={true} />);
expect(screen.getByLabelText("Running…")).toBeDisabled();
});
it("clicking toggle button calls onToggle with the routine", () => {
const routine = makeRoutine({ enabled: true });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
fireEvent.click(screen.getByLabelText("Disable Test Routine"));
expect(onToggle).toHaveBeenCalledWith(routine);
});
it("clicking edit button calls onEdit with the routine", () => {
const routine = makeRoutine();
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
fireEvent.click(screen.getByLabelText("Edit Test Routine"));
expect(onEdit).toHaveBeenCalledWith(routine);
});
it("clicking delete button shows confirm dialog and calls onDelete on confirm", async () => {
const routine = makeRoutine({ name: "My Routine" });
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
fireEvent.click(screen.getByLabelText("Delete My Routine"));
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalled();
});
expect(onDelete).toHaveBeenCalledWith(routine);
confirmSpy.mockRestore();
});
it("clicking delete button does not call onDelete when confirm is cancelled", async () => {
const routine = makeRoutine({ name: "My Routine" });
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
fireEvent.click(screen.getByLabelText("Delete My Routine"));
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalled();
});
expect(onDelete).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});
// ── Run History ───────────────────────────────────────────────────────
describe("Run history", () => {
it("shows run history toggle when runHistory.length > 0", () => {
const routine = makeRoutine({
runHistory: [
makeRunResult({ startedAt: "2026-04-08T00:00:00.000Z", completedAt: "2026-04-08T00:01:00.000Z" }),
],
});
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.getByText("Run History (1)")).toBeDefined();
});
it("expands/collapses run history on toggle click", async () => {
const routine = makeRoutine({
runHistory: [
makeRunResult({ startedAt: "2026-04-08T00:00:00.000Z", completedAt: "2026-04-08T00:01:00.000Z" }),
],
});
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
// Initially collapsed - no run detail
expect(screen.queryByText("Done")).toBeNull();
// Click to expand history list
fireEvent.click(screen.getByText("Run History (1)"));
// History list is visible but items are collapsed - click item header to expand
const itemHeader = screen.getByRole("button", { name: /Run #1: succeeded/i });
fireEvent.click(itemHeader);
await waitFor(() => {
expect(screen.getByText("Done")).toBeDefined();
});
// Click to collapse item
fireEvent.click(itemHeader);
await waitFor(() => {
expect(screen.queryByText("Done")).toBeNull();
});
});
it("shows max 10 history items with overflow indicator", () => {
const runHistory = Array.from({ length: 15 }, (_, i) =>
makeRunResult({ startedAt: `2026-04-0${i + 1}T00:00:00.000Z`, completedAt: `2026-04-0${i + 1}T00:01:00.000Z` })
);
const routine = makeRoutine({ runHistory });
render(<RoutineCard routine={routine} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
// Click to expand
fireEvent.click(screen.getByText("Run History (15)"));
// Should show only 10 items
expect(screen.getByText("…and 5 more")).toBeDefined();
});
it("does not show history section when runHistory is empty", () => {
render(<RoutineCard routine={makeRoutine({ runHistory: [] })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />);
expect(screen.queryByText("Run History")).toBeNull();
});
});
});

View File

@@ -0,0 +1,362 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RoutineEditor } from "../RoutineEditor";
import type { Routine, RoutineTriggerType } from "@fusion/core";
// Mock lucide-react
vi.mock("lucide-react", () => ({
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>,
}));
// Mock @fusion/core
vi.mock("@fusion/core", () => ({}));
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "routine-001",
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
executionPolicy: "parallel",
catchUpPolicy: "skip",
enabled: true,
runCount: 0,
runHistory: [],
createdAt: "2026-04-07T00:00:00.000Z",
updatedAt: "2026-04-07T00:00:00.000Z",
...overrides,
};
}
describe("RoutineEditor", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
// ── Create mode (no routine prop) ─────────────────────────────────────
describe("Create mode (no routine prop)", () => {
it("renders form with empty fields", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Name")).toHaveValue("");
expect(screen.getByLabelText("Description (optional)")).toHaveValue("");
});
it('shows "New Routine" heading', () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("New Routine")).toBeDefined();
});
it('shows "Create Routine" submit button', () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Create Routine")).toBeDefined();
});
it("defaults triggerType to 'cron'", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Cron")).toBeDefined();
// Cron expression input should be visible
expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 * * * *");
});
it("defaults executionPolicy to 'queue'", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Execution Policy")).toHaveValue("queue");
});
it("defaults catchUpPolicy to 'run_one'", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Catch-up Policy")).toHaveValue("run_one");
});
it("defaults enabled to true", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Enabled")).toBeChecked();
});
it("shows cron expression input when triggerType is 'cron'", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Cron Expression")).toBeDefined();
});
it("shows webhook inputs when triggerType is changed to 'webhook'", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Webhook"));
await waitFor(() => {
expect(screen.getByLabelText("Webhook Path")).toBeDefined();
expect(screen.getByLabelText("Webhook Secret (optional)")).toBeDefined();
});
});
it("shows API info when triggerType is changed to 'api'", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("API"));
await waitFor(() => {
expect(screen.getByLabelText("API Endpoint")).toBeDefined();
});
});
it("shows Manual trigger info when triggerType is changed to 'manual'", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Manual"));
await waitFor(() => {
expect(screen.getByText(/triggered manually/)).toBeDefined();
});
});
it("hides cron expression input when triggerType is not 'cron'", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Webhook"));
await waitFor(() => {
expect(screen.queryByLabelText("Cron Expression")).toBeNull();
});
});
});
// ── Edit mode (with routine prop) ─────────────────────────────────────
describe("Edit mode (with routine prop)", () => {
it("pre-fills all fields from the routine prop", () => {
const routine = makeRoutine({
name: "My Routine",
description: "My description",
trigger: { type: "cron", cronExpression: "0 9 * * *" },
executionPolicy: "reject",
catchUpPolicy: "run",
enabled: false,
});
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
expect(screen.getByLabelText("Description (optional)")).toHaveValue("My description");
expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 9 * * *");
expect(screen.getByLabelText("Execution Policy")).toHaveValue("reject");
expect(screen.getByLabelText("Catch-up Policy")).toHaveValue("run");
expect(screen.getByLabelText("Enabled")).not.toBeChecked();
});
it('shows "Edit Routine" heading', () => {
render(<RoutineEditor routine={makeRoutine()} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Edit Routine")).toBeDefined();
});
it('shows "Save Changes" submit button', () => {
render(<RoutineEditor routine={makeRoutine()} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Save Changes")).toBeDefined();
});
it("pre-fills webhook trigger fields", () => {
const routine = makeRoutine({
trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "secret123" },
});
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Webhook")).toBeDefined();
expect(screen.getByLabelText("Webhook Path")).toHaveValue("/trigger/test");
expect(screen.getByLabelText("Webhook Secret (optional)")).toHaveValue("secret123");
});
it("pre-fills api trigger fields", () => {
const routine = makeRoutine({
trigger: { type: "api", endpoint: "/api/test" },
});
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("API")).toBeDefined();
expect(screen.getByLabelText("API Endpoint")).toHaveValue("/api/test");
});
});
// ── Validation ───────────────────────────────────────────────────────
describe("Validation", () => {
it("shows error when name is empty on submit", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(screen.getByText("Name is required")).toBeDefined();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when triggerType is 'cron' and cronExpression is empty", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(screen.getByText("Cron expression is required")).toBeDefined();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when triggerType is 'cron' and cronExpression is invalid", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(screen.getByText(/Invalid cron format/)).toBeDefined();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when triggerType is 'webhook' and webhookPath is empty", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Webhook"));
fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(screen.getByText("Webhook path is required")).toBeDefined();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when triggerType is 'api' and endpoint is empty", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("API"));
fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(screen.getByText("API endpoint is required")).toBeDefined();
});
expect(onSubmit).not.toHaveBeenCalled();
});
});
// ── Submission ───────────────────────────────────────────────────────
describe("Submission", () => {
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" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "New Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
executionPolicy: "queue",
catchUpPolicy: "run_one",
enabled: true,
})
);
});
});
it("calls onSubmit with correct shape on valid edit", async () => {
const routine = makeRoutine({ name: "Old Name" });
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Name" } });
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Updated Name",
trigger: { type: "cron", cronExpression: "0 * * * *" },
})
);
});
});
it("disables submit button and shows 'Saving…' during submission", async () => {
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" } });
// Use role and name to find the submit button specifically
const submitButton = screen.getByRole("button", { name: "Create Routine" });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText("Saving…")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
});
it("re-enables submit button after submission completes", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
// Button should be re-enabled
expect(screen.getByText("Create Routine")).not.toBeDisabled();
});
it("builds correct webhook trigger on submit", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Webhook Routine" } });
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" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
trigger: {
type: "webhook",
webhookPath: "/trigger/my-hook",
secret: "my-secret",
},
})
);
});
});
it("builds correct api trigger on submit", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
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" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
trigger: {
type: "api",
endpoint: "/api/my-routine",
},
})
);
});
});
it("builds correct manual trigger on submit", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Manual Routine" } });
fireEvent.click(screen.getByText("Manual"));
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
trigger: { type: "manual" },
})
);
});
});
});
// ── Cancel ──────────────────────────────────────────────────────────
describe("Cancel", () => {
it("calls onCancel when cancel button is clicked", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Cancel"));
expect(onCancel).toHaveBeenCalled();
});
it("does not call onSubmit when cancel is clicked", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Cancel"));
expect(onSubmit).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,7 @@
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 } from "@fusion/core";
import type { ScheduledTask, AutomationRunResult, Routine } from "@fusion/core";
// Mock lucide-react
vi.mock("lucide-react", () => ({
@@ -15,6 +15,10 @@ vi.mock("lucide-react", () => ({
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>,
}));
// Mock @fusion/core (no runtime values needed — ScheduleForm inlines presets)
@@ -27,6 +31,11 @@ const mockUpdateAutomation = vi.fn();
const mockDeleteAutomation = vi.fn();
const mockRunAutomation = vi.fn();
const mockToggleAutomation = vi.fn();
const mockFetchRoutines = vi.fn();
const mockCreateRoutine = vi.fn();
const mockUpdateRoutine = vi.fn();
const mockDeleteRoutine = vi.fn();
const mockRunRoutine = vi.fn();
vi.mock("../../api", () => ({
fetchAutomations: (...args: any[]) => mockFetchAutomations(...args),
@@ -35,6 +44,11 @@ vi.mock("../../api", () => ({
deleteAutomation: (...args: any[]) => mockDeleteAutomation(...args),
runAutomation: (...args: any[]) => mockRunAutomation(...args),
toggleAutomation: (...args: any[]) => mockToggleAutomation(...args),
fetchRoutines: (...args: any[]) => mockFetchRoutines(...args),
createRoutine: (...args: any[]) => mockCreateRoutine(...args),
updateRoutine: (...args: any[]) => mockUpdateRoutine(...args),
deleteRoutine: (...args: any[]) => mockDeleteRoutine(...args),
runRoutine: (...args: any[]) => mockRunRoutine(...args),
}));
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
@@ -54,6 +68,23 @@ function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
};
}
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "routine-001",
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
executionPolicy: "queue",
catchUpPolicy: "run_one",
enabled: true,
runCount: 0,
runHistory: [],
createdAt: "2026-04-07T00:00:00.000Z",
updatedAt: "2026-04-07T00:00:00.000Z",
...overrides,
};
}
describe("ScheduledTasksModal", () => {
const onClose = vi.fn();
const addToast = vi.fn();
@@ -61,6 +92,7 @@ describe("ScheduledTasksModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchAutomations.mockResolvedValue([]);
mockFetchRoutines.mockResolvedValue([]);
});
it("renders modal with title", async () => {
@@ -292,4 +324,305 @@ describe("ScheduledTasksModal", () => {
});
});
});
// ── 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 = {
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");
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} />);
fireEvent.click(screen.getByText("Routines"));
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",
);
});
});
});
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);
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"));
await waitFor(() => {
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001");
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
});
confirmSpy.mockRestore();
});
});
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);
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 });
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
});
});
});
});

View File

@@ -15560,6 +15560,240 @@ html .column.drag-over * {
}
}
/* ── Routines ──────────────────────────────────────────────────── */
.routine-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: 48px 24px;
color: var(--text-muted);
text-align: center;
}
.routine-empty-state h4 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
.routine-empty-state p {
margin: 0;
font-size: 13px;
}
.routine-empty-state .btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: var(--space-sm);
}
.routine-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.routine-card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 16px;
background: var(--card-bg);
transition: border-color 0.15s;
}
.routine-card:hover {
border-color: var(--border-hover, var(--border));
}
.routine-card.disabled {
opacity: 0.55;
}
.routine-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
}
.routine-card-info {
flex: 1;
min-width: 0;
}
.routine-card-name-row {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.routine-card-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.routine-trigger-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid;
line-height: 1.5;
white-space: nowrap;
}
.routine-card-description {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.routine-card-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
}
.routine-card-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-md);
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
}
.routine-meta-item {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.routine-meta-label {
font-weight: 500;
}
.routine-cron {
font-size: 11px;
padding: 1px 5px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.routine-policy-badge {
font-size: 11px;
padding: 1px 7px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.routine-card-history {
margin-top: 10px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.routine-history-trigger-type {
font-size: 10px;
padding: 0px 4px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
color: var(--text-muted);
margin-left: 4px;
}
/* Routine form */
.routine-form {
padding: 0;
}
.routine-form .modal-actions {
padding: 16px 0 0;
border-top: 1px solid var(--border);
margin-top: var(--space-lg);
}
.routine-trigger-type-selector {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.routine-trigger-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
color 0.15s;
}
.routine-trigger-btn:hover {
border-color: var(--border-hover, var(--border));
background: var(--bg-secondary);
color: var(--text-primary);
}
.routine-trigger-btn.active {
border-color: var(--in-progress);
background: color-mix(in srgb, var(--in-progress) 10%, transparent);
color: var(--in-progress);
}
.routine-trigger-info {
display: block;
padding: var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 13px;
color: var(--text-secondary);
}
/* Routine mobile responsive */
@media (max-width: 768px) {
.routine-card-header {
flex-wrap: wrap;
gap: var(--space-sm);
}
.routine-card-actions {
flex-wrap: wrap;
width: 100%;
justify-content: flex-end;
}
.routine-trigger-type-selector {
flex-direction: column;
}
.routine-trigger-btn {
justify-content: center;
}
}
/* ── Activity Log Modal ─────────────────────────────────────────── */
.activity-log-modal {