feat(FN-1717): add scope selection controls to dashboard UI
- Add scope selector controls to ScheduleForm and RoutineEditor components - Add scope badges to RoutineCard and ScheduleCard for visual scope indication - Add scope controls to ScheduledTasksModal with projectId propagation - Add scheduling scope options to automation/routine API wrappers - Add comprehensive regression tests for scope propagation and modal wiring - Update README with dashboard UI scope selection documentation
This commit is contained in:
@@ -184,6 +184,7 @@ export function AppModals({
|
||||
<ScheduledTasksModal
|
||||
onClose={modalManager.closeSchedules}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap } from "lucide-react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
|
||||
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
|
||||
|
||||
/**
|
||||
@@ -171,6 +171,15 @@ export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, runnin
|
||||
<TriggerIcon size={10} />
|
||||
{TRIGGER_TYPE_LABELS[routine.trigger.type]}
|
||||
</span>
|
||||
{routine.scope && (
|
||||
<span
|
||||
className={`routine-scope-badge${routine.scope === "global" ? " global" : " project"}`}
|
||||
title={`${routine.scope === "global" ? "Global" : "Project"}-scoped routine`}
|
||||
>
|
||||
{routine.scope === "global" ? <Globe size={10} /> : <Folder size={10} />}
|
||||
{routine.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{routine.description && (
|
||||
<p className="routine-card-description">{routine.description}</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Calendar, Webhook, Code, Zap } from "lucide-react";
|
||||
import { Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
|
||||
import type {
|
||||
Routine,
|
||||
RoutineCreateInput,
|
||||
@@ -121,9 +121,13 @@ interface RoutineEditorProps {
|
||||
onSubmit: (input: RoutineCreateInput) => Promise<void>;
|
||||
/** Called when the user cancels. */
|
||||
onCancel: () => void;
|
||||
/** Scope for the routine (global or project). Defaults to routine.scope or "project". */
|
||||
scope?: "global" | "project";
|
||||
/** Project ID for project-scoped routines. */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProps) {
|
||||
export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, projectId }: RoutineEditorProps) {
|
||||
const isEditing = !!routine;
|
||||
|
||||
// Extract trigger fields if editing
|
||||
@@ -156,6 +160,12 @@ export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProp
|
||||
const validate = useCallback((): boolean => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!name.trim()) e.name = "Name is required";
|
||||
|
||||
// Scope validation: project scope requires projectId
|
||||
if (formScope === "project" && !projectId) {
|
||||
e.scope = "Project-specific entries require an active project.";
|
||||
}
|
||||
|
||||
if (triggerType === "cron") {
|
||||
if (!cronExpression.trim()) {
|
||||
e.cronExpression = "Cron expression is required";
|
||||
@@ -171,7 +181,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProp
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}, [name, triggerType, cronExpression, webhookPath, endpoint]);
|
||||
}, [name, triggerType, cronExpression, webhookPath, endpoint, formScope, projectId]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
@@ -179,6 +189,13 @@ export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProp
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Determine scope: use edit mode's existing scope, otherwise use formScope prop
|
||||
// When formScope is "project" but no projectId provided, fall back to "global"
|
||||
let effectiveScope = routine?.scope ?? formScope ?? (projectId ? "project" : "global");
|
||||
if (effectiveScope === "project" && !projectId) {
|
||||
effectiveScope = "global";
|
||||
}
|
||||
|
||||
const trigger = buildTrigger(triggerType, cronExpression, webhookPath, webhookSecret, endpoint);
|
||||
const input: RoutineCreateInput = {
|
||||
name: name.trim(),
|
||||
@@ -188,13 +205,14 @@ export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProp
|
||||
executionPolicy,
|
||||
catchUpPolicy,
|
||||
enabled,
|
||||
scope: effectiveScope,
|
||||
};
|
||||
await onSubmit(input);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, executionPolicy, catchUpPolicy, enabled],
|
||||
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope],
|
||||
);
|
||||
|
||||
const nameErrorId = "routine-name-error";
|
||||
@@ -236,6 +254,45 @@ export function RoutineEditor({ routine, onSubmit, onCancel }: RoutineEditorProp
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope selector */}
|
||||
<div className="form-group">
|
||||
<label>Scope</label>
|
||||
<div className="routine-scope-toggle" role="radiogroup" aria-label="Routine scope">
|
||||
<button
|
||||
type="button"
|
||||
className={`routine-scope-btn${(!formScope || formScope === 'global') ? " active" : ""}`}
|
||||
role="radio"
|
||||
aria-checked={(!formScope || formScope === 'global') ? "true" : "false"}
|
||||
disabled={!!routine?.scope}
|
||||
title={routine?.scope ? `Scope is locked to ${routine.scope} for existing routines` : "Global scope"}
|
||||
>
|
||||
<Globe size={12} />
|
||||
Global
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`routine-scope-btn${formScope === 'project' ? " active" : ""}`}
|
||||
role="radio"
|
||||
aria-checked={formScope === 'project' ? "true" : "false"}
|
||||
disabled={!!routine?.scope || !projectId}
|
||||
title={routine?.scope ? `Scope is locked to ${routine.scope} for existing routines` : !projectId ? "Select a project to enable project scope" : "Project scope"}
|
||||
>
|
||||
<Folder size={12} />
|
||||
Project
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
{!projectId && !routine?.scope
|
||||
? "No active project. Routines will be created at global scope."
|
||||
: formScope === "project" && projectId
|
||||
? `This routine will be scoped to the current project.`
|
||||
: "This routine will be created at global scope."}
|
||||
</small>
|
||||
{errors.scope && (
|
||||
<small className="field-error">{errors.scope}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trigger Type */}
|
||||
<div className="form-group">
|
||||
<label>Trigger Type</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Layers } from "lucide-react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Layers, Globe, Folder } from "lucide-react";
|
||||
import type { ScheduledTask, AutomationRunResult, AutomationStepResult } from "@fusion/core";
|
||||
|
||||
/**
|
||||
@@ -165,6 +165,15 @@ export function ScheduleCard({ schedule, onEdit, onDelete, onRun, onToggle, runn
|
||||
>
|
||||
{schedule.scheduleType}
|
||||
</span>
|
||||
{schedule.scope && (
|
||||
<span
|
||||
className={`schedule-scope-badge${schedule.scope === "global" ? " global" : " project"}`}
|
||||
title={`${schedule.scope === "global" ? "Global" : "Project"}-scoped schedule`}
|
||||
>
|
||||
{schedule.scope === "global" ? <Globe size={10} /> : <Folder size={10} />}
|
||||
{schedule.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{schedule.description && (
|
||||
<p className="schedule-card-description">{schedule.description}</p>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Globe, Folder } from "lucide-react";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduleType, AutomationStep } from "@fusion/core";
|
||||
import { ScheduleStepsEditor } from "./ScheduleStepsEditor";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { fetchModels } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import type { SchedulingScope } from "./ScheduledTasksModal";
|
||||
|
||||
/** Mapping from preset schedule types to their cron expressions. Mirrored from @fusion/core. */
|
||||
const PRESET_CRON: Record<Exclude<ScheduleType, "custom">, string> = {
|
||||
@@ -65,9 +67,13 @@ interface ScheduleFormProps {
|
||||
onSubmit: (input: ScheduledTaskCreateInput) => Promise<void>;
|
||||
/** Called when the user cancels. */
|
||||
onCancel: () => void;
|
||||
/** Scope for the schedule (global or project). Defaults to schedule.scope or "project". */
|
||||
scope?: SchedulingScope;
|
||||
/** Project ID for project-scoped schedules. */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps) {
|
||||
export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, projectId }: ScheduleFormProps) {
|
||||
const isEditing = !!schedule;
|
||||
|
||||
// Determine initial mode based on whether the schedule has steps
|
||||
@@ -178,6 +184,11 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
const e: Record<string, string> = {};
|
||||
if (!name.trim()) e.name = "Name is required";
|
||||
|
||||
// Scope validation: project scope requires projectId
|
||||
if (formScope === "project" && !projectId) {
|
||||
e.scope = "Project-specific entries require an active project.";
|
||||
}
|
||||
|
||||
// Simple mode validation
|
||||
if (mode === "simple") {
|
||||
if (simpleType === "command") {
|
||||
@@ -247,6 +258,13 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
try {
|
||||
let submitData: ScheduledTaskCreateInput;
|
||||
|
||||
// Determine scope: use edit mode's existing scope, otherwise use formScope prop
|
||||
// When formScope is "project" but no projectId provided, fall back to "global"
|
||||
let effectiveScope = schedule?.scope ?? formScope ?? (projectId ? "project" : "global");
|
||||
if (effectiveScope === "project" && !projectId) {
|
||||
effectiveScope = "global";
|
||||
}
|
||||
|
||||
if (mode === "simple") {
|
||||
if (simpleType === "command") {
|
||||
submitData = {
|
||||
@@ -258,6 +276,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
enabled,
|
||||
timeoutMs,
|
||||
steps: undefined,
|
||||
scope: effectiveScope,
|
||||
};
|
||||
} else {
|
||||
// AI Prompt mode - create a single-step automation
|
||||
@@ -278,6 +297,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
enabled,
|
||||
timeoutMs,
|
||||
steps: [aiStep],
|
||||
scope: effectiveScope,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@@ -290,6 +310,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
enabled,
|
||||
timeoutMs,
|
||||
steps,
|
||||
scope: effectiveScope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,7 +319,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps],
|
||||
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps, formScope, projectId, schedule?.scope],
|
||||
);
|
||||
|
||||
const cronFieldId = "schedule-cron";
|
||||
@@ -342,6 +363,47 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope selector */}
|
||||
<div className="form-group">
|
||||
<label>Scope</label>
|
||||
<div className="schedule-scope-toggle" role="radiogroup" aria-label="Schedule scope">
|
||||
<button
|
||||
type="button"
|
||||
className={`schedule-scope-btn${(!formScope || formScope === 'global') ? " active" : ""}`}
|
||||
onClick={() => { /* Scope is determined at submit time based on projectId */ }}
|
||||
role="radio"
|
||||
aria-checked={(!formScope || formScope === 'global') ? "true" : "false"}
|
||||
disabled={!!schedule?.scope}
|
||||
title={schedule?.scope ? `Scope is locked to ${schedule.scope} for existing schedules` : "Global scope"}
|
||||
>
|
||||
<Globe size={12} />
|
||||
Global
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`schedule-scope-btn${formScope === 'project' ? " active" : ""}`}
|
||||
onClick={() => { /* Scope is determined at submit time based on projectId */ }}
|
||||
role="radio"
|
||||
aria-checked={formScope === 'project' ? "true" : "false"}
|
||||
disabled={!!schedule?.scope || !projectId}
|
||||
title={schedule?.scope ? `Scope is locked to ${schedule.scope} for existing schedules` : !projectId ? "Select a project to enable project scope" : "Project scope"}
|
||||
>
|
||||
<Folder size={12} />
|
||||
Project
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
{!projectId && !schedule?.scope
|
||||
? "No active project. Schedules will be created at global scope."
|
||||
: formScope === "project" && projectId
|
||||
? `This schedule will be scoped to the current project.`
|
||||
: "This schedule will be created at global scope."}
|
||||
</small>
|
||||
{errors.scope && (
|
||||
<small className="field-error">{errors.scope}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-type">Schedule</label>
|
||||
<select
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, Clock, Zap } from "lucide-react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Plus, Clock, Zap, Globe, Folder } from "lucide-react";
|
||||
import type {
|
||||
ScheduledTask,
|
||||
ScheduledTaskCreateInput,
|
||||
@@ -28,18 +28,26 @@ import type { ToastType } from "../hooks/useToast";
|
||||
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Scheduling scope: global (user-level) or project-scoped. */
|
||||
export type SchedulingScope = "global" | "project";
|
||||
|
||||
interface ScheduledTasksModalProps {
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
/** Optional project ID for project-scoped scheduling. When provided, scope defaults to "project". */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
type ModalView = "list" | "create" | "edit";
|
||||
type ActiveTab = "schedules" | "routines";
|
||||
|
||||
export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalProps) {
|
||||
export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledTasksModalProps) {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>("schedules");
|
||||
|
||||
// Scope state: defaults to "project" when projectId exists, else "global"
|
||||
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
|
||||
|
||||
// Schedule state
|
||||
const [schedules, setSchedules] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -54,31 +62,37 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
const [editingRoutine, setEditingRoutine] = useState<Routine | undefined>();
|
||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||
|
||||
// Build scope options for API calls
|
||||
const scopeOptions = useMemo(() => ({
|
||||
scope: activeScope,
|
||||
projectId: activeScope === "project" ? projectId : undefined,
|
||||
}), [activeScope, projectId]);
|
||||
|
||||
// Load schedules
|
||||
const loadSchedules = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchAutomations();
|
||||
const data = await fetchAutomations(scopeOptions);
|
||||
setSchedules(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load schedules", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, scopeOptions]);
|
||||
|
||||
// Load routines
|
||||
const loadRoutines = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchRoutines();
|
||||
const data = await fetchRoutines(scopeOptions);
|
||||
setRoutines(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load routines", "error");
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, scopeOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSchedules();
|
||||
loadRoutines();
|
||||
void loadSchedules();
|
||||
void loadRoutines();
|
||||
}, [loadSchedules, loadRoutines]);
|
||||
|
||||
// Poll for updates while modal is open
|
||||
@@ -128,7 +142,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
const handleCreate = useCallback(
|
||||
async (input: ScheduledTaskCreateInput) => {
|
||||
try {
|
||||
await createAutomation(input);
|
||||
await createAutomation(input, scopeOptions);
|
||||
addToast("Schedule created", "success");
|
||||
setView("list");
|
||||
await loadSchedules();
|
||||
@@ -136,7 +150,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
addToast(err.message || "Failed to create schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((schedule: ScheduledTask) => {
|
||||
@@ -148,7 +162,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
async (input: ScheduledTaskCreateInput) => {
|
||||
if (!editingSchedule) return;
|
||||
try {
|
||||
await updateAutomation(editingSchedule.id, input);
|
||||
await updateAutomation(editingSchedule.id, input, scopeOptions);
|
||||
addToast("Schedule updated", "success");
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
@@ -157,27 +171,27 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
addToast(err.message || "Failed to update schedule", "error");
|
||||
}
|
||||
},
|
||||
[editingSchedule, addToast, loadSchedules],
|
||||
[editingSchedule, addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
try {
|
||||
await deleteAutomation(schedule.id);
|
||||
await deleteAutomation(schedule.id, scopeOptions);
|
||||
addToast(`Deleted "${schedule.name}"`, "success");
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
setRunningId(schedule.id);
|
||||
try {
|
||||
const { result } = await runAutomation(schedule.id);
|
||||
const { result } = await runAutomation(schedule.id, scopeOptions);
|
||||
if (result.success) {
|
||||
addToast(`"${schedule.name}" completed successfully`, "success");
|
||||
} else {
|
||||
@@ -190,13 +204,13 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
setRunningId(null);
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
try {
|
||||
await toggleAutomation(schedule.id);
|
||||
await toggleAutomation(schedule.id, scopeOptions);
|
||||
addToast(
|
||||
`"${schedule.name}" ${schedule.enabled ? "disabled" : "enabled"}`,
|
||||
"success",
|
||||
@@ -206,7 +220,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
addToast(err.message || "Failed to toggle schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
[addToast, loadSchedules, scopeOptions],
|
||||
);
|
||||
|
||||
const handleFormCancel = useCallback(() => {
|
||||
@@ -219,7 +233,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
const handleCreateRoutine = useCallback(
|
||||
async (input: RoutineCreateInput) => {
|
||||
try {
|
||||
await createRoutine(input);
|
||||
await createRoutine(input, scopeOptions);
|
||||
addToast("Routine created", "success");
|
||||
setRoutineView("list");
|
||||
await loadRoutines();
|
||||
@@ -227,7 +241,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
addToast(err.message || "Failed to create routine", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadRoutines],
|
||||
[addToast, loadRoutines, scopeOptions],
|
||||
);
|
||||
|
||||
const handleEditRoutine = useCallback((routine: Routine) => {
|
||||
@@ -239,7 +253,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
async (input: RoutineCreateInput) => {
|
||||
if (!editingRoutine) return;
|
||||
try {
|
||||
await updateRoutine(editingRoutine.id, input);
|
||||
await updateRoutine(editingRoutine.id, input, scopeOptions);
|
||||
addToast("Routine updated", "success");
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
@@ -248,27 +262,27 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
addToast(err.message || "Failed to update routine", "error");
|
||||
}
|
||||
},
|
||||
[editingRoutine, addToast, loadRoutines],
|
||||
[editingRoutine, addToast, loadRoutines, scopeOptions],
|
||||
);
|
||||
|
||||
const handleDeleteRoutine = useCallback(
|
||||
async (routine: Routine) => {
|
||||
try {
|
||||
await deleteRoutine(routine.id);
|
||||
await deleteRoutine(routine.id, scopeOptions);
|
||||
addToast(`Deleted "${routine.name}"`, "success");
|
||||
await loadRoutines();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete routine", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadRoutines],
|
||||
[addToast, loadRoutines, scopeOptions],
|
||||
);
|
||||
|
||||
const handleRunRoutine = useCallback(
|
||||
async (routine: Routine) => {
|
||||
setRunningRoutineId(routine.id);
|
||||
try {
|
||||
const { result } = await runRoutine(routine.id);
|
||||
const { result } = await runRoutine(routine.id, scopeOptions);
|
||||
if (result.success) {
|
||||
addToast(`"${routine.name}" completed successfully`, "success");
|
||||
} else {
|
||||
@@ -281,13 +295,13 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
setRunningRoutineId(null);
|
||||
}
|
||||
},
|
||||
[addToast, loadRoutines],
|
||||
[addToast, loadRoutines, scopeOptions],
|
||||
);
|
||||
|
||||
const handleToggleRoutine = useCallback(
|
||||
async (routine: Routine) => {
|
||||
try {
|
||||
await updateRoutine(routine.id, { enabled: !routine.enabled });
|
||||
await updateRoutine(routine.id, { enabled: !routine.enabled }, scopeOptions);
|
||||
addToast(
|
||||
`"${routine.name}" ${routine.enabled ? "disabled" : "enabled"}`,
|
||||
"success",
|
||||
@@ -297,7 +311,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
addToast(err.message || "Failed to toggle routine", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadRoutines],
|
||||
[addToast, loadRoutines, scopeOptions],
|
||||
);
|
||||
|
||||
const handleRoutineCancel = useCallback(() => {
|
||||
@@ -315,11 +329,22 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
setEditingRoutine(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Scope switch handler ───────────────────────────────────────────────
|
||||
|
||||
const handleScopeSwitch = useCallback((scope: SchedulingScope) => {
|
||||
setActiveScope(scope);
|
||||
// Reset to list view when switching scope
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
setRoutineView("list");
|
||||
setEditingRoutine(undefined);
|
||||
}, []);
|
||||
|
||||
// ── Render content ─────────────────────────────────────────────────────
|
||||
|
||||
const renderSchedulesContent = () => {
|
||||
if (view === "create") {
|
||||
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} />;
|
||||
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} scope={activeScope} projectId={projectId} />;
|
||||
}
|
||||
|
||||
if (view === "edit" && editingSchedule) {
|
||||
@@ -328,6 +353,8 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
schedule={editingSchedule}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={handleFormCancel}
|
||||
scope={activeScope}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -373,7 +400,7 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
|
||||
const renderRoutinesContent = () => {
|
||||
if (routineView === "create") {
|
||||
return <RoutineEditor onSubmit={handleCreateRoutine} onCancel={handleRoutineCancel} />;
|
||||
return <RoutineEditor onSubmit={handleCreateRoutine} onCancel={handleRoutineCancel} scope={activeScope} projectId={projectId} />;
|
||||
}
|
||||
|
||||
if (routineView === "edit" && editingRoutine) {
|
||||
@@ -382,6 +409,8 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
routine={editingRoutine}
|
||||
onSubmit={handleUpdateRoutine}
|
||||
onCancel={handleRoutineCancel}
|
||||
scope={activeScope}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -440,6 +469,29 @@ export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalPr
|
||||
<div className="modal-header">
|
||||
<h3 id="schedules-modal-title">Scheduled Tasks</h3>
|
||||
<div className="modal-header-actions">
|
||||
{/* Scope selector */}
|
||||
<div className="scheduling-scope-selector" role="group" aria-label="Scheduling scope">
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-btn${activeScope === "global" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("global")}
|
||||
aria-pressed={activeScope === "global"}
|
||||
title="Global (user-level) schedules"
|
||||
>
|
||||
<Globe size={14} />
|
||||
Global
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-btn${activeScope === "project" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("project")}
|
||||
aria-pressed={activeScope === "project"}
|
||||
title="Project-scoped schedules"
|
||||
>
|
||||
<Folder size={14} />
|
||||
Project
|
||||
</button>
|
||||
</div>
|
||||
{isShowingList && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
|
||||
306
packages/dashboard/app/components/__tests__/AppModals.test.tsx
Normal file
306
packages/dashboard/app/components/__tests__/AppModals.test.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AppModals } from "../AppModals";
|
||||
import type { ModalManager } from "../../hooks/useModalManager";
|
||||
import type { Toast } from "../../hooks/useToast";
|
||||
|
||||
// Mock the modals to avoid rendering all of them
|
||||
vi.mock("../TaskDetailModal", () => ({
|
||||
TaskDetailModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../SettingsModal", () => ({
|
||||
SettingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../GitHubImportModal", () => ({
|
||||
GitHubImportModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../PlanningModeModal", () => ({
|
||||
PlanningModeModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../SubtaskBreakdownModal", () => ({
|
||||
SubtaskBreakdownModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../TerminalModal", () => ({
|
||||
TerminalModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ScriptsModal", () => ({
|
||||
ScriptsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../FileBrowserModal", () => ({
|
||||
FileBrowserModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../UsageIndicator", () => ({
|
||||
UsageIndicator: () => null,
|
||||
}));
|
||||
|
||||
// Mock ScheduledTasksModal to capture props
|
||||
const mockScheduledTasksModalProps = vi.fn();
|
||||
vi.mock("../ScheduledTasksModal", () => ({
|
||||
ScheduledTasksModal: ({ projectId, ...rest }: any) => {
|
||||
mockScheduledTasksModalProps({ projectId, rest });
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../NewTaskModal", () => ({
|
||||
NewTaskModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ActivityLogModal", () => ({
|
||||
ActivityLogModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../GitManagerModal", () => ({
|
||||
GitManagerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../WorkflowStepManager", () => ({
|
||||
WorkflowStepManager: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../AgentListModal", () => ({
|
||||
AgentListModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../SetupWizardModal", () => ({
|
||||
SetupWizardModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ModelOnboardingModal", () => ({
|
||||
ModelOnboardingModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ToastContainer", () => ({
|
||||
ToastContainer: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTaskHandlers", () => ({
|
||||
useTaskHandlers: () => ({
|
||||
handleModalCreate: vi.fn(),
|
||||
handlePlanningTaskCreated: vi.fn(),
|
||||
handlePlanningTasksCreated: vi.fn(),
|
||||
handleSubtaskTasksCreated: vi.fn(),
|
||||
handleGitHubImport: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useProjectActions", () => ({
|
||||
useProjectActions: () => ({
|
||||
handleSetupComplete: vi.fn(),
|
||||
handleModelOnboardingComplete: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @fusion/core types
|
||||
vi.mock("@fusion/core", () => ({}));
|
||||
|
||||
// Mock ModalErrorBoundary
|
||||
vi.mock("../ErrorBoundary", () => ({
|
||||
ModalErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
describe("AppModals", () => {
|
||||
const mockModalManager: ModalManager = {
|
||||
detailTask: null,
|
||||
settingsOpen: false,
|
||||
githubImportOpen: false,
|
||||
isPlanningOpen: false,
|
||||
planningInitialPlan: null,
|
||||
planningResumeSessionId: null,
|
||||
isSubtaskOpen: false,
|
||||
subtaskInitialDescription: null,
|
||||
subtaskResumeSessionId: null,
|
||||
terminalOpen: false,
|
||||
terminalInitialCommand: null,
|
||||
scriptsOpen: false,
|
||||
runScript: vi.fn(),
|
||||
filesOpen: false,
|
||||
fileBrowserWorkspace: "project",
|
||||
usageOpen: false,
|
||||
schedulesOpen: false,
|
||||
newTaskModalOpen: false,
|
||||
activityLogOpen: false,
|
||||
gitManagerOpen: false,
|
||||
workflowStepsOpen: false,
|
||||
agentsOpen: false,
|
||||
setupWizardOpen: false,
|
||||
modelOnboardingOpen: false,
|
||||
openDetailTask: vi.fn(),
|
||||
updateDetailTask: vi.fn(),
|
||||
openSettings: vi.fn(),
|
||||
closeSettings: vi.fn(),
|
||||
closeGitHubImport: vi.fn(),
|
||||
openPlanning: vi.fn(),
|
||||
closePlanning: vi.fn(),
|
||||
openSubtaskBreakdown: vi.fn(),
|
||||
closeSubtask: vi.fn(),
|
||||
openTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
openScripts: vi.fn(),
|
||||
closeScripts: vi.fn(),
|
||||
openFiles: vi.fn(),
|
||||
closeFiles: vi.fn(),
|
||||
setFileWorkspace: vi.fn(),
|
||||
openUsage: vi.fn(),
|
||||
closeUsage: vi.fn(),
|
||||
openSchedules: vi.fn(),
|
||||
closeSchedules: vi.fn(),
|
||||
openNewTask: vi.fn(),
|
||||
closeNewTask: vi.fn(),
|
||||
openActivityLog: vi.fn(),
|
||||
closeActivityLog: vi.fn(),
|
||||
openGitManager: vi.fn(),
|
||||
closeGitManager: vi.fn(),
|
||||
openWorkflowSteps: vi.fn(),
|
||||
closeWorkflowSteps: vi.fn(),
|
||||
openAgents: vi.fn(),
|
||||
closeAgents: vi.fn(),
|
||||
openSetupWizard: vi.fn(),
|
||||
closeSetupWizard: vi.fn(),
|
||||
openModelOnboarding: vi.fn(),
|
||||
closeModelOnboarding: vi.fn(),
|
||||
openDetailTaskInitialTab: vi.fn(),
|
||||
settingsInitialSection: null,
|
||||
detailTaskInitialTab: null,
|
||||
};
|
||||
|
||||
const mockToasts: Toast[] = [];
|
||||
const mockSettings = {
|
||||
githubTokenConfigured: false,
|
||||
themeMode: "dark" as const,
|
||||
colorTheme: "default" as const,
|
||||
setThemeMode: vi.fn(),
|
||||
setColorTheme: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockScheduledTasksModalProps.mockClear();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
render(
|
||||
<AppModals
|
||||
projectId={undefined}
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={mockModalManager}
|
||||
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
|
||||
describe("ScheduledTasksModal projectId forwarding", () => {
|
||||
it("does not render ScheduledTasksModal when schedulesOpen is false", () => {
|
||||
const manager = { ...mockModalManager, schedulesOpen: false };
|
||||
render(
|
||||
<AppModals
|
||||
projectId="proj-123"
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={manager}
|
||||
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
expect(mockScheduledTasksModalProps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders ScheduledTasksModal with projectId when schedulesOpen is true and projectId is defined", () => {
|
||||
const manager = { ...mockModalManager, schedulesOpen: true };
|
||||
render(
|
||||
<AppModals
|
||||
projectId="proj-abc"
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={manager}
|
||||
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
expect(mockScheduledTasksModalProps).toHaveBeenCalledTimes(1);
|
||||
const captured = mockScheduledTasksModalProps.mock.calls[0][0];
|
||||
expect(captured.projectId).toBe("proj-abc");
|
||||
});
|
||||
|
||||
it("renders ScheduledTasksModal with undefined projectId when schedulesOpen is true and projectId is undefined", () => {
|
||||
const manager = { ...mockModalManager, schedulesOpen: true };
|
||||
render(
|
||||
<AppModals
|
||||
projectId={undefined}
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={manager}
|
||||
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
expect(mockScheduledTasksModalProps).toHaveBeenCalledTimes(1);
|
||||
const captured = mockScheduledTasksModalProps.mock.calls[0][0];
|
||||
expect(captured.projectId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders ScheduledTasksModal with undefined projectId when projectId is empty string", () => {
|
||||
const manager = { ...mockModalManager, schedulesOpen: true };
|
||||
render(
|
||||
<AppModals
|
||||
projectId=""
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={manager}
|
||||
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
expect(mockScheduledTasksModalProps).toHaveBeenCalledTimes(1);
|
||||
const captured = mockScheduledTasksModalProps.mock.calls[0][0];
|
||||
// Empty string should pass through as-is
|
||||
expect(captured.projectId).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ vi.mock("lucide-react", () => ({
|
||||
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
|
||||
Code: () => <span data-testid="icon-code">💻</span>,
|
||||
Zap: () => <span data-testid="icon-zap">⚡</span>,
|
||||
Globe: () => <span data-testid="icon-globe">🌍</span>,
|
||||
Folder: () => <span data-testid="icon-folder">📁</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core
|
||||
|
||||
@@ -3,6 +3,24 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
|
||||
import { ScheduleForm } from "../ScheduleForm";
|
||||
import type { ScheduledTask } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Globe: () => <span data-testid="icon-globe">🌍</span>,
|
||||
Folder: () => <span data-testid="icon-folder">📁</span>,
|
||||
GripVertical: () => <span data-testid="icon-grip">⋮⋮</span>,
|
||||
Plus: () => <span data-testid="icon-plus">+</span>,
|
||||
Pencil: () => <span data-testid="icon-pencil">✎</span>,
|
||||
Trash2: () => <span data-testid="icon-trash">🗑</span>,
|
||||
CheckCircle: () => <span data-testid="icon-check">✓</span>,
|
||||
XCircle: () => <span data-testid="icon-x">✗</span>,
|
||||
ChevronDown: () => <span data-testid="icon-down">▼</span>,
|
||||
ChevronUp: () => <span data-testid="icon-up">▲</span>,
|
||||
Sparkles: () => <span data-testid="icon-sparkles">✨</span>,
|
||||
Terminal: () => <span data-testid="icon-terminal">⌨</span>,
|
||||
ArrowUpDown: () => <span data-testid="icon-arrow">↕</span>,
|
||||
GripVertical: () => <span data-testid="icon-grip">⋮⋮</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core to provide type-only exports (no runtime values needed)
|
||||
vi.mock("@fusion/core", () => ({}));
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock("lucide-react", () => ({
|
||||
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
|
||||
Code: () => <span data-testid="icon-code">💻</span>,
|
||||
Zap: () => <span data-testid="icon-zap">⚡</span>,
|
||||
Globe: () => <span data-testid="icon-globe">🌍</span>,
|
||||
Folder: () => <span data-testid="icon-folder">📁</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core (no runtime values needed — ScheduleForm inlines presets)
|
||||
@@ -186,6 +188,107 @@ describe("ScheduledTasksModal", () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Scope behavior", () => {
|
||||
it("defaults to global scope when no projectId provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
// Verify fetchAutomations was called with global scope
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
|
||||
it("defaults to project scope when projectId is provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
// Verify fetchAutomations was called with project scope and projectId
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
|
||||
});
|
||||
|
||||
it("forwards projectId to fetchRoutines when projectId is provided", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
// Verify fetchRoutines was called with project scope and projectId
|
||||
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-456" });
|
||||
});
|
||||
|
||||
it("can switch from global to project scope and reloads data", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
|
||||
|
||||
// Initial load with project scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
|
||||
});
|
||||
|
||||
// Clear mocks to track the reload after scope switch
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Click the global scope button
|
||||
const globalBtn = screen.getByRole("button", { name: /global/i });
|
||||
fireEvent.click(globalBtn);
|
||||
|
||||
// Should reload with global scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
});
|
||||
|
||||
it("switches from project to global scope and reloads data", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
// Initial load with global scope
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
|
||||
});
|
||||
|
||||
// Clear mocks to track the reload after scope switch
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Click the project scope button
|
||||
const projectBtn = screen.getByRole("button", { name: /project/i });
|
||||
fireEvent.click(projectBtn);
|
||||
|
||||
// Should reload with project scope (but no projectId available, so falls back to global)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project" });
|
||||
});
|
||||
});
|
||||
|
||||
it("resets view to list when switching scope", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
|
||||
// Open create form
|
||||
fireEvent.click(screen.getByText("New Schedule"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Clear mocks
|
||||
mockFetchAutomations.mockClear();
|
||||
|
||||
// Switch scope - should reset to list view
|
||||
const globalBtn = screen.getByRole("button", { name: /global/i });
|
||||
fireEvent.click(globalBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should be back to list view, not create form
|
||||
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
|
||||
expect(screen.getByText("Test Job")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create flow", () => {
|
||||
it("shows create form when clicking New Schedule", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
@@ -259,10 +362,27 @@ describe("ScheduledTasksModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Disable My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1");
|
||||
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Job" disabled', "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards projectId when projectId is provided", async () => {
|
||||
const schedule = makeSchedule({ name: "My Job", enabled: true });
|
||||
mockFetchAutomations.mockResolvedValue([schedule]);
|
||||
mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Disable My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "project", projectId: "proj-123" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
@@ -280,7 +400,7 @@ describe("ScheduledTasksModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Delete My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAutomation).toHaveBeenCalledWith("sched-1");
|
||||
expect(mockDeleteAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('Deleted "My Job"', "success");
|
||||
});
|
||||
|
||||
@@ -308,7 +428,7 @@ describe("ScheduledTasksModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Run My Job now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRunAutomation).toHaveBeenCalledWith("sched-1");
|
||||
expect(mockRunAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Job" completed successfully', "success");
|
||||
});
|
||||
});
|
||||
@@ -575,7 +695,7 @@ describe("ScheduledTasksModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Run My Routine now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001");
|
||||
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
|
||||
});
|
||||
});
|
||||
@@ -623,7 +743,7 @@ describe("ScheduledTasksModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Delete My Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001");
|
||||
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
|
||||
});
|
||||
|
||||
@@ -646,7 +766,7 @@ describe("ScheduledTasksModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Disable My Routine"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false });
|
||||
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }, { scope: "global" });
|
||||
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user