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:
gsxdsm
2026-03-31 19:53:35 -07:00
parent 3d7c9ef070
commit 40164e6b04
5 changed files with 247 additions and 8 deletions

View File

@@ -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>
)}
</>
)}

View File

@@ -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]);

View File

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

View File

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