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

@@ -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>