feat(FN-1924): merge fusion/fn-1924
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user