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:
Fusion
2026-04-15 17:36:18 -07:00
committed by gsxdsm
parent 831e07a625
commit 653a25ecd0
14 changed files with 1193 additions and 81 deletions

View File

@@ -4515,3 +4515,262 @@ describe("Settings API wrappers", () => {
});
});
});
// ── Automation / Scheduling Scope Tests ─────────────────────────────────────────
function mockSchedulingFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
contentType = "application/json"
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? contentType : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
}
describe("Automation API scope forwarding", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetchAutomations sends GET to /automations without scope by default", async () => {
const { fetchAutomations } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
await fetchAutomations();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/automations");
});
it("fetchAutomations includes scope=global when specified", async () => {
const { fetchAutomations } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
await fetchAutomations({ scope: "global" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/automations?scope=global");
});
it("fetchAutomations includes scope=project and projectId when project-scoped", async () => {
const { fetchAutomations } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
await fetchAutomations({ scope: "project", projectId: "proj-123" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/automations");
expect(url).toContain("scope=project");
expect(url).toContain("projectId=proj-123");
});
it("createAutomation forwards scope context in query params", async () => {
const { createAutomation } = await import("./api");
const fakeSchedule = {
id: "sched-001",
name: "Test",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "echo test",
enabled: true,
scope: "project",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue({
ok: true,
status: 201,
statusText: "Created",
headers: { get: () => "application/json" },
json: () => Promise.resolve(fakeSchedule),
text: () => Promise.resolve(JSON.stringify(fakeSchedule)),
} as unknown as Response);
await createAutomation(
{ name: "Test", scheduleType: "daily", command: "echo test", enabled: true, scope: "project" },
{ scope: "project", projectId: "proj-123" }
);
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/automations?scope=project&projectId=proj-123");
const body = JSON.parse(opts.body);
expect(body.name).toBe("Test");
expect(body.scope).toBe("project");
});
it("createAutomation forwards scope context without projectId for global scope", async () => {
const { createAutomation } = await import("./api");
const fakeSchedule = {
id: "sched-001",
name: "Test",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "echo test",
enabled: true,
scope: "global",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue({
ok: true,
status: 201,
statusText: "Created",
headers: { get: () => "application/json" },
json: () => Promise.resolve(fakeSchedule),
text: () => Promise.resolve(JSON.stringify(fakeSchedule)),
} as unknown as Response);
await createAutomation(
{ name: "Test", scheduleType: "daily", command: "echo test", enabled: true },
{ scope: "global" }
);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/automations?scope=global");
expect(url).not.toContain("projectId");
});
it("runAutomation forwards scope context", async () => {
const { runAutomation } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { schedule: {}, result: { success: true } }));
await runAutomation("sched-001", { scope: "project", projectId: "proj-123" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/automations/sched-001/run?scope=project&projectId=proj-123");
});
it("toggleAutomation forwards scope context", async () => {
const { toggleAutomation } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "sched-001", enabled: false }));
await toggleAutomation("sched-001", { scope: "global" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/automations/sched-001/toggle?scope=global");
});
});
describe("Routine API scope forwarding", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetchRoutines sends GET to /routines without scope by default", async () => {
const { fetchRoutines } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
await fetchRoutines();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/routines");
});
it("fetchRoutines includes scope=global when specified", async () => {
const { fetchRoutines } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
await fetchRoutines({ scope: "global" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/routines?scope=global");
});
it("fetchRoutines includes scope=project and projectId when project-scoped", async () => {
const { fetchRoutines } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
await fetchRoutines({ scope: "project", projectId: "proj-456" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/routines");
expect(url).toContain("scope=project");
expect(url).toContain("projectId=proj-456");
});
it("createRoutine forwards scope context in query params", async () => {
const { createRoutine } = await import("./api");
const fakeRoutine = {
id: "routine-001",
name: "Test Routine",
enabled: true,
trigger: { type: "manual" },
scope: "project",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue({
ok: true,
status: 201,
statusText: "Created",
headers: { get: () => "application/json" },
json: () => Promise.resolve(fakeRoutine),
text: () => Promise.resolve(JSON.stringify(fakeRoutine)),
} as unknown as Response);
await createRoutine(
{ name: "Test Routine", agentId: "", trigger: { type: "manual" as const }, enabled: true },
{ scope: "project", projectId: "proj-456" }
);
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/routines?scope=project&projectId=proj-456");
const body = JSON.parse(opts.body);
expect(body.name).toBe("Test Routine");
expect(body.scope).toBeUndefined(); // scope is in query, not body (body comes from RoutineCreateInput)
});
it("updateRoutine forwards scope context", async () => {
const { updateRoutine } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "routine-001", name: "Updated" }));
await updateRoutine("routine-001", { name: "Updated" }, { scope: "project", projectId: "proj-456" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/routines/routine-001?scope=project&projectId=proj-456");
});
it("deleteRoutine forwards scope context", async () => {
const { deleteRoutine } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue({
ok: true,
status: 204,
statusText: "No Content",
headers: { get: () => "application/json" },
json: () => Promise.resolve(null),
text: () => Promise.resolve(""),
} as unknown as Response);
await deleteRoutine("routine-001", { scope: "global" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/routines/routine-001?scope=global");
});
it("runRoutine forwards scope context", async () => {
const { runRoutine } = await import("./api");
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { routine: {}, result: { success: true } }));
await runRoutine("routine-001", { scope: "project", projectId: "proj-789" });
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/routines/routine-001/trigger?scope=project&projectId=proj-789");
});
});

View File

@@ -1762,56 +1762,84 @@ export function connectPlanningStream(
// ── Automation / Scheduled Tasks ──────────────────────────────────
/**
* Options for scheduling scope (global vs project-scoped automations/routines).
* When scope is "project", projectId must be provided.
*/
export type SchedulingScopeOptions = {
/** Scope for scheduling operations: "global" or "project". Defaults to "project" on the server. */
scope?: "global" | "project";
/** Project ID required when scope is "project". */
projectId?: string;
};
/**
* Build URL suffix with scope and projectId query params.
* Mirrors the backend's parseScopeParam logic: scope goes in query param.
*/
function withSchedulingScope(path: string, options?: SchedulingScopeOptions): string {
const params = new URLSearchParams();
if (options?.scope) {
params.set("scope", options.scope);
}
if (options?.projectId) {
params.set("projectId", options.projectId);
}
const suffix = params.toString();
if (!suffix) return path;
return `${path}?${suffix}`;
}
/** Response from the manual run trigger endpoint. */
export interface AutomationRunResponse {
schedule: ScheduledTask;
result: AutomationRunResult;
}
export function fetchAutomations(): Promise<ScheduledTask[]> {
return api<ScheduledTask[]>("/automations");
export function fetchAutomations(options?: SchedulingScopeOptions): Promise<ScheduledTask[]> {
return api<ScheduledTask[]>(withSchedulingScope("/automations", options));
}
export function fetchAutomation(id: string): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}`);
export function fetchAutomation(id: string, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}`, options));
}
export function createAutomation(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = input;
return api<ScheduledTask>("/automations", {
export function createAutomation(input: ScheduledTaskCreateInput, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
// Forward all input fields including scope metadata (scope may be set on input or in options)
return api<ScheduledTask>(withSchedulingScope("/automations", options), {
method: "POST",
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps }),
body: JSON.stringify(input),
});
}
export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = updates;
return api<ScheduledTask>(`/automations/${id}`, {
export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
// Forward all update fields including scope metadata
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}`, options), {
method: "PATCH",
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps }),
body: JSON.stringify(updates),
});
}
export async function deleteAutomation(id: string): Promise<void> {
await api(`/automations/${id}`, {
export async function deleteAutomation(id: string, options?: SchedulingScopeOptions): Promise<void> {
await api(withSchedulingScope(`/automations/${id}`, options), {
method: "DELETE",
});
}
export function runAutomation(id: string): Promise<AutomationRunResponse> {
return api<AutomationRunResponse>(`/automations/${id}/run`, {
export function runAutomation(id: string, options?: SchedulingScopeOptions): Promise<AutomationRunResponse> {
return api<AutomationRunResponse>(withSchedulingScope(`/automations/${id}/run`, options), {
method: "POST",
});
}
export function toggleAutomation(id: string): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}/toggle`, {
export function toggleAutomation(id: string, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}/toggle`, options), {
method: "POST",
});
}
export function reorderAutomationSteps(id: string, stepIds: string[]): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}/steps/reorder`, {
export function reorderAutomationSteps(id: string, stepIds: string[], options?: SchedulingScopeOptions): Promise<ScheduledTask> {
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}/steps/reorder`, options), {
method: "POST",
body: JSON.stringify({ stepIds }),
});
@@ -1824,46 +1852,48 @@ export interface RoutineRunResponse {
result: RoutineExecutionResult;
}
export function fetchRoutines(): Promise<Routine[]> {
return api<Routine[]>("/routines");
export function fetchRoutines(options?: SchedulingScopeOptions): Promise<Routine[]> {
return api<Routine[]>(withSchedulingScope("/routines", options));
}
export function fetchRoutine(id: string): Promise<Routine> {
return api<Routine>(`/routines/${id}`);
export function fetchRoutine(id: string, options?: SchedulingScopeOptions): Promise<Routine> {
return api<Routine>(withSchedulingScope(`/routines/${id}`, options));
}
export function createRoutine(input: RoutineCreateInput): Promise<Routine> {
return api<Routine>("/routines", {
export function createRoutine(input: RoutineCreateInput, options?: SchedulingScopeOptions): Promise<Routine> {
// Forward all input fields including scope metadata
return api<Routine>(withSchedulingScope("/routines", options), {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateRoutine(id: string, updates: RoutineUpdateInput): Promise<Routine> {
return api<Routine>(`/routines/${id}`, {
export function updateRoutine(id: string, updates: RoutineUpdateInput, options?: SchedulingScopeOptions): Promise<Routine> {
// Forward all update fields including scope metadata
return api<Routine>(withSchedulingScope(`/routines/${id}`, options), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
export async function deleteRoutine(id: string): Promise<void> {
await api(`/routines/${id}`, {
export async function deleteRoutine(id: string, options?: SchedulingScopeOptions): Promise<void> {
await api(withSchedulingScope(`/routines/${id}`, options), {
method: "DELETE",
});
}
export function runRoutine(id: string): Promise<RoutineRunResponse> {
return api<RoutineRunResponse>(`/routines/${id}/trigger`, {
export function runRoutine(id: string, options?: SchedulingScopeOptions): Promise<RoutineRunResponse> {
return api<RoutineRunResponse>(withSchedulingScope(`/routines/${id}/trigger`, options), {
method: "POST",
});
}
export function fetchRoutineRuns(id: string): Promise<RoutineExecutionResult[]> {
return api<RoutineExecutionResult[]>(`/routines/${id}/runs`);
export function fetchRoutineRuns(id: string, options?: SchedulingScopeOptions): Promise<RoutineExecutionResult[]> {
return api<RoutineExecutionResult[]>(withSchedulingScope(`/routines/${id}/runs`, options));
}
export function triggerRoutineWebhook(id: string, payload?: Record<string, unknown>): Promise<RoutineRunResponse> {
return api<RoutineRunResponse>(`/routines/${id}/webhook`, {
export function triggerRoutineWebhook(id: string, payload?: Record<string, unknown>, options?: SchedulingScopeOptions): Promise<RoutineRunResponse> {
return api<RoutineRunResponse>(withSchedulingScope(`/routines/${id}/webhook`, options), {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});

View File

@@ -184,6 +184,7 @@ export function AppModals({
<ScheduledTasksModal
onClose={modalManager.closeSchedules}
addToast={addToast}
projectId={projectId}
/>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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("");
});
});
});

View File

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

View File

@@ -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", () => ({}));

View File

@@ -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");
});
});

View File

@@ -16027,6 +16027,141 @@ html .column.drag-over * {
max-height: 70vh;
}
/* Scheduling scope selector */
.scheduling-scope-selector {
display: flex;
align-items: center;
gap: 2px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 2px;
}
.scope-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.scope-btn:hover {
background: var(--card-hover);
color: var(--text);
}
.scope-btn.active {
background: var(--todo);
color: white;
}
.scope-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Schedule form scope toggle */
.schedule-scope-toggle {
display: flex;
align-items: center;
gap: 2px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 2px;
width: fit-content;
}
.schedule-scope-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.schedule-scope-btn:hover:not(:disabled) {
background: var(--card-hover);
color: var(--text);
}
.schedule-scope-btn.active {
background: var(--todo);
color: white;
}
.schedule-scope-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.schedule-scope-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Routine form scope toggle */
.routine-scope-toggle {
display: flex;
align-items: center;
gap: 2px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 2px;
width: fit-content;
}
.routine-scope-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.routine-scope-btn:hover:not(:disabled) {
background: var(--card-hover);
color: var(--text);
}
.routine-scope-btn.active {
background: var(--todo);
color: white;
}
.routine-scope-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.routine-scope-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.schedule-empty-state {
display: flex;
flex-direction: column;
@@ -16113,6 +16248,30 @@ html .column.drag-over * {
white-space: nowrap;
}
.schedule-scope-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid;
line-height: 1.4;
white-space: nowrap;
text-transform: lowercase;
}
.schedule-scope-badge.global {
color: var(--text-muted);
border-color: var(--text-dim);
}
.schedule-scope-badge.project {
color: var(--todo);
border-color: var(--todo);
}
.schedule-card-description {
margin: 4px 0 0;
font-size: 12px;
@@ -16657,6 +16816,30 @@ html .column.drag-over * {
white-space: nowrap;
}
.routine-scope-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid;
line-height: 1.4;
white-space: nowrap;
text-transform: lowercase;
}
.routine-scope-badge.global {
color: var(--text-muted);
border-color: var(--text-dim);
}
.routine-scope-badge.project {
color: var(--todo);
border-color: var(--todo);
}
.routine-card-description {
margin: 4px 0 0;
font-size: 12px;