feat(FN-1924): merge fusion/fn-1924

This commit is contained in:
gsxdsm
2026-04-16 08:38:55 -07:00
parent 3a0350da60
commit 3f412e9aa3
4 changed files with 164 additions and 15 deletions

View File

@@ -71,9 +71,11 @@ interface ScheduleFormProps {
scope?: SchedulingScope;
/** Project ID for project-scoped schedules. */
projectId?: string;
/** Called when the user changes the scope via the toggle buttons. */
onScopeChange?: (scope: SchedulingScope) => void;
}
export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, projectId }: ScheduleFormProps) {
export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, projectId, onScopeChange }: ScheduleFormProps) {
const isEditing = !!schedule;
// Determine initial mode based on whether the schedule has steps
@@ -95,6 +97,14 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
const [steps, setSteps] = useState<AutomationStep[]>(schedule?.steps ?? []);
const [hasEditingSteps, setHasEditingSteps] = useState(false);
// Scope toggle state
const [localScope, setLocalScope] = useState<SchedulingScope>(formScope ?? "global");
// Sync localScope when formScope prop changes (e.g., when parent resets)
useEffect(() => {
if (formScope) setLocalScope(formScope);
}, [formScope]);
// Simple mode type toggle state
const [simpleType, setSimpleType] = useState<SimpleType>(() => {
// Detect if editing a simple-mode AI prompt schedule
@@ -216,7 +226,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
if (!name.trim()) e.name = "Name is required";
// Scope validation: project scope requires projectId
if (formScope === "project" && !projectId) {
if (localScope === "project" && !projectId) {
e.scope = "Project-specific entries require an active project.";
}
@@ -289,7 +299,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, command, prompt, modelProvider, modelId, mode, simpleType, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps, taskDescription]);
}, [name, command, prompt, modelProvider, modelId, mode, simpleType, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps, taskDescription, localScope]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -299,9 +309,9 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
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");
// Determine scope: use edit mode's existing scope, otherwise use localScope
// When localScope is "project" but no projectId provided, fall back to "global"
let effectiveScope = schedule?.scope ?? localScope;
if (effectiveScope === "project" && !projectId) {
effectiveScope = "global";
}
@@ -383,7 +393,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
setSubmitting(false);
}
},
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps, formScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn],
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps, localScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn],
);
const cronFieldId = "schedule-cron";
@@ -435,10 +445,10 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
<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 */ }}
className={`schedule-scope-btn${localScope === 'global' ? " active" : ""}`}
onClick={() => { setLocalScope("global"); onScopeChange?.("global"); }}
role="radio"
aria-checked={(!formScope || formScope === 'global') ? "true" : "false"}
aria-checked={localScope === 'global' ? "true" : "false"}
disabled={!!schedule?.scope}
title={schedule?.scope ? `Scope is locked to ${schedule.scope} for existing schedules` : "Global scope"}
>
@@ -447,10 +457,10 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
</button>
<button
type="button"
className={`schedule-scope-btn${formScope === 'project' ? " active" : ""}`}
onClick={() => { /* Scope is determined at submit time based on projectId */ }}
className={`schedule-scope-btn${localScope === 'project' ? " active" : ""}`}
onClick={() => { setLocalScope("project"); onScopeChange?.("project"); }}
role="radio"
aria-checked={formScope === 'project' ? "true" : "false"}
aria-checked={localScope === '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"}
>
@@ -461,7 +471,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
<small>
{!projectId && !schedule?.scope
? "No active project. Schedules will be created at global scope."
: formScope === "project" && projectId
: localScope === "project" && projectId
? `This schedule will be scoped to the current project.`
: "This schedule will be created at global scope."}
</small>

View File

@@ -344,7 +344,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
const renderSchedulesContent = () => {
if (view === "create") {
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} scope={activeScope} projectId={projectId} />;
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} scope={activeScope} projectId={projectId} onScopeChange={handleScopeSwitch} />;
}
if (view === "edit" && editingSchedule) {
@@ -355,6 +355,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
onCancel={handleFormCancel}
scope={activeScope}
projectId={projectId}
onScopeChange={handleScopeSwitch}
/>
);
}

View File

@@ -500,6 +500,105 @@ describe("ScheduleForm", () => {
});
});
describe("scope toggle", () => {
it("renders scope toggle buttons", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "🌍Global" })).toBeDefined();
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDefined();
});
it("clicking Global scope button calls onScopeChange with 'global'", () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" onScopeChange={onScopeChange} />);
fireEvent.click(screen.getByRole("radio", { name: "🌍Global" }));
expect(onScopeChange).toHaveBeenCalledWith("global");
});
it("clicking Project scope button calls onScopeChange with 'project'", () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" onScopeChange={onScopeChange} />);
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
expect(onScopeChange).toHaveBeenCalledWith("project");
});
it("scope toggle updates visual active state", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" />);
// Global should be active initially
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "false");
// Click Project button
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
// Project should now be active
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "false");
});
it("Project button is disabled when no projectId", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDisabled();
});
it("scope buttons are disabled when editing existing schedule with scope", () => {
const schedule = makeSchedule({ scope: "global" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: "🌍Global" })).toBeDisabled();
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDisabled();
});
it("submit uses localScope when user changes scope", async () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" onScopeChange={onScopeChange} />);
// Fill name and command
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to project scope
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
scope: "project",
}),
);
});
});
it("localScope syncs when formScope prop changes", () => {
const { rerender } = render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" />);
// Global should be active initially
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "true");
// Change prop to project scope
rerender(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="test-project" />);
// Project should now be active
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "true");
});
it("shows project scope description when localScope is project", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="test-project" />);
expect(screen.getByText(/scoped to the current project/)).toBeDefined();
});
it("shows global scope description when localScope is global", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByText(/created at global scope/)).toBeDefined();
});
it("shows no project description when no projectId and no schedule", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText(/No active project/)).toBeDefined();
});
});
describe("multi-step mode", () => {
it("switches to Multi-Step mode and adds a command step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);

View File

@@ -346,6 +346,45 @@ describe("ScheduledTasksModal", () => {
expect(addToast).toHaveBeenCalledWith("Schedule created", "success");
});
});
it("changing scope in create form updates modal's activeScope and reloads data", async () => {
const schedule = makeSchedule({ name: "Test Job" });
mockFetchAutomations
.mockResolvedValueOnce([schedule]) // initial load with project scope
.mockResolvedValueOnce([]); // reload with global scope after scope switch
mockCreateAutomation.mockResolvedValue(schedule);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
// Initial load with project scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
expect(screen.getByText("Test Job")).toBeDefined();
});
// Open create form
fireEvent.click(screen.getByText("New Schedule"));
await waitFor(() => {
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
});
// Clear mocks to track reload
mockFetchAutomations.mockClear();
// Click Global scope button in the form (note: icon chars in accessible name)
const globalBtn = screen.getByRole("radio", { name: "🌍Global" });
fireEvent.click(globalBtn);
// Modal should reload with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
// Should be back in list view
await waitFor(() => {
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
});
});
});
describe("toggle", () => {