fix(KB-649): fix multi-step scheduled task editor step addition and validation
- Fix step addition flow in ScheduleStepsEditor component - Add form validation for multi-step schedules in ScheduleForm - Add integration tests for multi-step scheduled task flow - Update related CLI and core tests for consistency - Include changeset for the fix
This commit is contained in:
@@ -67,6 +67,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
|
||||
const [timeoutMs, setTimeoutMs] = useState<number>(schedule?.timeoutMs ?? 300000);
|
||||
const [steps, setSteps] = useState<AutomationStep[]>(schedule?.steps ?? []);
|
||||
const [hasEditingSteps, setHasEditingSteps] = useState(false);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -83,6 +84,34 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
if (!name.trim()) e.name = "Name is required";
|
||||
if (mode === "simple" && !command.trim()) e.command = "Command is required";
|
||||
if (mode === "advanced" && steps.length === 0) e.steps = "At least one step is required";
|
||||
|
||||
// Validate step content in multi-step mode
|
||||
if (mode === "advanced" && steps.length > 0) {
|
||||
const incompleteSteps: string[] = [];
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (!step.name?.trim()) {
|
||||
incompleteSteps.push(`Step ${i + 1}: Name is required`);
|
||||
}
|
||||
if (step.type === "command" && !step.command?.trim()) {
|
||||
incompleteSteps.push(`Step ${i + 1}: Command is required`);
|
||||
}
|
||||
if (step.type === "ai-prompt" && !step.prompt?.trim()) {
|
||||
incompleteSteps.push(`Step ${i + 1}: Prompt is required`);
|
||||
}
|
||||
}
|
||||
|
||||
if (incompleteSteps.length > 0) {
|
||||
e.steps = incompleteSteps.join("; ");
|
||||
}
|
||||
|
||||
// Check if any steps are currently being edited
|
||||
if (hasEditingSteps) {
|
||||
e.stepsEditing = "Please save or cancel all step edits before saving the schedule";
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleType === "custom") {
|
||||
if (!cronExpression.trim()) {
|
||||
e.cronExpression = "Cron expression is required for custom schedules";
|
||||
@@ -95,7 +124,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}, [name, command, mode, steps, scheduleType, cronExpression, timeoutMs]);
|
||||
}, [name, command, mode, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
@@ -249,10 +278,17 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScheduleStepsEditor steps={steps} onChange={setSteps} />
|
||||
<ScheduleStepsEditor
|
||||
steps={steps}
|
||||
onChange={setSteps}
|
||||
onEditingChange={setHasEditingSteps}
|
||||
/>
|
||||
{errors.steps && (
|
||||
<small className="field-error">{errors.steps}</small>
|
||||
)}
|
||||
{errors.stepsEditing && (
|
||||
<small className="field-error">{errors.stepsEditing}</small>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, GripVertical } from "lucide-react";
|
||||
import type { AutomationStep, AutomationStepType } from "@fusion/core";
|
||||
import { StepTypeBadge } from "./StepTypeBadge";
|
||||
@@ -6,6 +6,8 @@ import { StepTypeBadge } from "./StepTypeBadge";
|
||||
interface ScheduleStepsEditorProps {
|
||||
steps: AutomationStep[];
|
||||
onChange: (steps: AutomationStep[]) => void;
|
||||
/** Called when editing state changes. Useful for parent form validation. */
|
||||
onEditingChange?: (isEditing: boolean) => void;
|
||||
}
|
||||
|
||||
function generateStepId(): string {
|
||||
@@ -193,9 +195,14 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleStepsEditor({ steps, onChange }: ScheduleStepsEditorProps) {
|
||||
export function ScheduleStepsEditor({ steps, onChange, onEditingChange }: ScheduleStepsEditorProps) {
|
||||
const [editingStepId, setEditingStepId] = useState<string | null>(null);
|
||||
|
||||
// Notify parent when editing state changes
|
||||
useEffect(() => {
|
||||
onEditingChange?.(editingStepId !== null);
|
||||
}, [editingStepId, onEditingChange]);
|
||||
|
||||
const handleAddStep = useCallback((type: AutomationStepType) => {
|
||||
const newStep = createEmptyStep(type);
|
||||
onChange([...steps, newStep]);
|
||||
|
||||
@@ -211,4 +211,160 @@ describe("ScheduleForm", () => {
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-step mode", () => {
|
||||
it("switches to Multi-Step mode and adds a command step", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Fill in basic info
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Multi-Step" } });
|
||||
|
||||
// Switch to Multi-Step mode
|
||||
fireEvent.click(screen.getByText("Multi-Step"));
|
||||
|
||||
// Add a command step
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
|
||||
// Step editor should be open
|
||||
expect(screen.getByText("Save Step")).toBeDefined();
|
||||
|
||||
// Fill in step details - use placeholder to find the command field
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
|
||||
|
||||
// Save the step
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
|
||||
// Step should be visible in the list
|
||||
expect(screen.getByText("Run Tests")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds an AI prompt step", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Schedule" } });
|
||||
fireEvent.click(screen.getByText("Multi-Step"));
|
||||
|
||||
// Add an AI prompt step
|
||||
fireEvent.click(screen.getByText("Add AI Prompt Step"));
|
||||
|
||||
// Fill in step details
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Summarize Results" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. Summarize the test results and highlight any failures"), {
|
||||
target: { value: "Summarize test output" }
|
||||
});
|
||||
|
||||
// Save the step
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
|
||||
// Step should be visible
|
||||
expect(screen.getByText("Summarize Results")).toBeDefined();
|
||||
});
|
||||
|
||||
it("prevents submission with incomplete steps (missing command)", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Incomplete Schedule" } });
|
||||
fireEvent.click(screen.getByText("Multi-Step"));
|
||||
|
||||
// Add a step but don't fill in the command
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
|
||||
// Note: Not filling in the command field
|
||||
|
||||
// Try to save step - this should fail validation
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
expect(screen.getByText("Command is required")).toBeDefined();
|
||||
|
||||
// Cancel the step editor - click the Cancel button in the step editor (not the form Cancel)
|
||||
const cancelButtons = screen.getAllByText("Cancel");
|
||||
// First Cancel is in the step editor, second is the form Cancel
|
||||
fireEvent.click(cancelButtons[0]!);
|
||||
|
||||
// Try to submit the form - should show error about incomplete steps
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
expect(screen.getByText(/Step 1: Command is required/)).toBeDefined();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents submission when steps are being edited", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Editing Schedule" } });
|
||||
fireEvent.click(screen.getByText("Multi-Step"));
|
||||
|
||||
// Add a step and keep editor open
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
|
||||
// Don't save - keep editor open
|
||||
|
||||
// Try to submit the form
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
|
||||
// Should show editing error
|
||||
expect(screen.getByText(/Please save or cancel all step edits/)).toBeDefined();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("successfully creates a multi-step schedule with valid steps", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Complete Multi-Step" } });
|
||||
fireEvent.click(screen.getByText("Multi-Step"));
|
||||
|
||||
// Add first step
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Build" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm run build" } });
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
|
||||
// Add second step
|
||||
fireEvent.click(screen.getByText("Add AI Prompt Step"));
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Review" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. Summarize the test results and highlight any failures"), {
|
||||
target: { value: "Review the build output" }
|
||||
});
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
|
||||
// Submit the form
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Complete Multi-Step",
|
||||
steps: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Build", type: "command", command: "npm run build" }),
|
||||
expect.objectContaining({ name: "Review", type: "ai-prompt", prompt: "Review the build output" }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing multi-step schedule", () => {
|
||||
const schedule = makeSchedule({
|
||||
steps: [
|
||||
{ id: "step-1", type: "command", name: "Build", command: "npm run build" },
|
||||
],
|
||||
});
|
||||
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Should be in Multi-Step mode by default when schedule has steps
|
||||
expect(screen.getByText("Steps (1)")).toBeDefined();
|
||||
expect(screen.getByText("Build")).toBeDefined();
|
||||
|
||||
// Add another step
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Test" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
|
||||
// Should show both steps
|
||||
expect(screen.getByText("Steps (2)")).toBeDefined();
|
||||
expect(screen.getByText("Build")).toBeDefined();
|
||||
expect(screen.getByText("Test")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { ScheduleStepsEditor } from "../ScheduleStepsEditor";
|
||||
import type { AutomationStep } from "@fusion/core";
|
||||
|
||||
@@ -138,14 +139,42 @@ describe("ScheduleStepsEditor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("step editing", () => {
|
||||
it("shows step editor when edit button is clicked", () => {
|
||||
describe("step editor state", () => {
|
||||
function StatefulEditor(props: Omit<ScheduleStepsEditorProps, 'onChange'>) {
|
||||
const [steps, setSteps] = useState<AutomationStep[]>(props.steps);
|
||||
return <ScheduleStepsEditor steps={steps} onChange={setSteps} />;
|
||||
}
|
||||
|
||||
it("opens step editor automatically when adding a new step", () => {
|
||||
render(<StatefulEditor steps={[]} />);
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
// After adding, the editor should be open (showing Save Step button)
|
||||
expect(screen.getByText("Save Step")).toBeDefined();
|
||||
});
|
||||
|
||||
it("notifies parent when editing state changes", () => {
|
||||
const onEditingChange = vi.fn();
|
||||
function StatefulEditorWithCallback(props: Omit<ScheduleStepsEditorProps, 'onChange'>) {
|
||||
const [steps, setSteps] = useState<AutomationStep[]>(props.steps);
|
||||
return <ScheduleStepsEditor steps={steps} onChange={setSteps} onEditingChange={onEditingChange} />;
|
||||
}
|
||||
render(<StatefulEditorWithCallback steps={[]} />);
|
||||
|
||||
// Should be called with true when opening editor
|
||||
fireEvent.click(screen.getByText("Add Command Step"));
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
// Should be called with false when canceling
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("opens step editor for existing steps when edit is clicked", () => {
|
||||
const steps = [makeStep({ id: "s1", name: "Build" })];
|
||||
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Edit Build"));
|
||||
// Editor should show form fields
|
||||
expect(screen.getByLabelText("Step Name")).toBeDefined();
|
||||
expect(screen.getByText("Save Step")).toBeDefined();
|
||||
expect(screen.getByLabelText("Step Name")).toHaveProperty("value", "Build");
|
||||
});
|
||||
|
||||
it("closes editor on cancel", () => {
|
||||
|
||||
Reference in New Issue
Block a user