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:
286
packages/dashboard/app/components/RoutineCard.tsx
Normal file
286
packages/dashboard/app/components/RoutineCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
433
packages/dashboard/app/components/RoutineEditor.tsx
Normal file
433
packages/dashboard/app/components/RoutineEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
279
packages/dashboard/app/components/__tests__/RoutineCard.test.tsx
Normal file
279
packages/dashboard/app/components/__tests__/RoutineCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user