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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user