feat(FN-1923): merge fusion/fn-1923
This commit is contained in:
@@ -58,7 +58,7 @@ function generateStepId(): string {
|
||||
}
|
||||
|
||||
type ScheduleMode = "simple" | "advanced";
|
||||
type SimpleType = "command" | "ai-prompt";
|
||||
type SimpleType = "command" | "ai-prompt" | "create-task";
|
||||
|
||||
interface ScheduleFormProps {
|
||||
/** Existing schedule for editing. Omit for create mode. */
|
||||
@@ -77,10 +77,12 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
const isEditing = !!schedule;
|
||||
|
||||
// Determine initial mode based on whether the schedule has steps
|
||||
// But single ai-prompt steps from simple mode should show in simple mode
|
||||
// But single ai-prompt and create-task steps from simple mode should show in simple mode
|
||||
const isSimpleAiPrompt = schedule?.steps && schedule.steps.length === 1 &&
|
||||
schedule.steps[0].type === "ai-prompt" && !schedule.command;
|
||||
const initialMode: ScheduleMode = (schedule?.steps && schedule.steps.length > 0 && !isSimpleAiPrompt) ? "advanced" : "simple";
|
||||
const isSimpleCreateTask = schedule?.steps && schedule.steps.length === 1 &&
|
||||
schedule.steps[0].type === "create-task" && !schedule.command;
|
||||
const initialMode: ScheduleMode = (schedule?.steps && schedule.steps.length > 0 && !isSimpleAiPrompt && !isSimpleCreateTask) ? "advanced" : "simple";
|
||||
|
||||
const [mode, setMode] = useState<ScheduleMode>(initialMode);
|
||||
const [name, setName] = useState(schedule?.name ?? "");
|
||||
@@ -99,6 +101,10 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) {
|
||||
return "ai-prompt";
|
||||
}
|
||||
// Detect if editing a simple-mode create-task schedule
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) {
|
||||
return "create-task";
|
||||
}
|
||||
return "command";
|
||||
});
|
||||
const [prompt, setPrompt] = useState(() => {
|
||||
@@ -111,14 +117,39 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) {
|
||||
return schedule.steps[0].modelProvider ?? "";
|
||||
}
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) {
|
||||
return schedule.steps[0].modelProvider ?? "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [modelId, setModelId] = useState(() => {
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) {
|
||||
return schedule.steps[0].modelId ?? "";
|
||||
}
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) {
|
||||
return schedule.steps[0].modelId ?? "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
// Create-task fields
|
||||
const [taskTitle, setTaskTitle] = useState(() => {
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) {
|
||||
return schedule.steps[0].taskTitle ?? "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [taskDescription, setTaskDescription] = useState(() => {
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) {
|
||||
return schedule.steps[0].taskDescription ?? "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [taskColumn, setTaskColumn] = useState(() => {
|
||||
if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) {
|
||||
return schedule.steps[0].taskColumn ?? "triage";
|
||||
}
|
||||
return "triage";
|
||||
});
|
||||
|
||||
// Model dropdown state
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
@@ -193,10 +224,20 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
if (mode === "simple") {
|
||||
if (simpleType === "command") {
|
||||
if (!command.trim()) e.command = "Command is required";
|
||||
} else {
|
||||
} else if (simpleType === "ai-prompt") {
|
||||
// AI Prompt mode
|
||||
if (!prompt.trim()) e.prompt = "Prompt is required";
|
||||
|
||||
// Model consistency check: both must be set or both must be empty
|
||||
const hasProvider = !!modelProvider.trim();
|
||||
const hasModelId = !!modelId.trim();
|
||||
if (hasProvider !== hasModelId) {
|
||||
e.model = "Both model provider and model ID must be set, or both must be empty";
|
||||
}
|
||||
} else if (simpleType === "create-task") {
|
||||
// Create Task mode
|
||||
if (!taskDescription.trim()) e.taskDescription = "Task description is required";
|
||||
|
||||
// Model consistency check: both must be set or both must be empty
|
||||
const hasProvider = !!modelProvider.trim();
|
||||
const hasModelId = !!modelId.trim();
|
||||
@@ -248,7 +289,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]);
|
||||
}, [name, command, prompt, modelProvider, modelId, mode, simpleType, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps, taskDescription]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
@@ -278,7 +319,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
steps: undefined,
|
||||
scope: effectiveScope,
|
||||
};
|
||||
} else {
|
||||
} else if (simpleType === "ai-prompt") {
|
||||
// AI Prompt mode - create a single-step automation
|
||||
const aiStep: AutomationStep = {
|
||||
id: generateStepId(),
|
||||
@@ -299,6 +340,29 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
steps: [aiStep],
|
||||
scope: effectiveScope,
|
||||
};
|
||||
} else {
|
||||
// Create Task mode - create a single-step create-task automation
|
||||
const createTaskStep: AutomationStep = {
|
||||
id: generateStepId(),
|
||||
type: "create-task",
|
||||
name: name.trim(),
|
||||
taskTitle: taskTitle.trim() || undefined,
|
||||
taskDescription: taskDescription.trim(),
|
||||
taskColumn: taskColumn,
|
||||
modelProvider: modelProvider.trim() || undefined,
|
||||
modelId: modelId.trim() || undefined,
|
||||
};
|
||||
submitData = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
scheduleType,
|
||||
cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined,
|
||||
command: "",
|
||||
enabled,
|
||||
timeoutMs,
|
||||
steps: [createTaskStep],
|
||||
scope: effectiveScope,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
submitData = {
|
||||
@@ -319,7 +383,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],
|
||||
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps, formScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn],
|
||||
);
|
||||
|
||||
const cronFieldId = "schedule-cron";
|
||||
@@ -328,6 +392,8 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
const commandErrorId = "schedule-command-error";
|
||||
const promptErrorId = "schedule-prompt-error";
|
||||
const modelErrorId = "schedule-model-error";
|
||||
const taskDescriptionErrorId = "schedule-task-description-error";
|
||||
const taskModelErrorId = "schedule-task-model-error";
|
||||
const timeoutErrorId = "schedule-timeout-error";
|
||||
|
||||
return (
|
||||
@@ -498,6 +564,15 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
>
|
||||
AI Prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`schedule-mode-btn${simpleType === "create-task" ? " active" : ""}`}
|
||||
onClick={() => setSimpleType("create-task")}
|
||||
role="radio"
|
||||
aria-checked={simpleType === "create-task"}
|
||||
>
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -519,7 +594,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
<small>Shell command to execute. Runs with your user permissions.</small>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : simpleType === "ai-prompt" ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-prompt">Prompt</label>
|
||||
@@ -558,6 +633,68 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-task-title">Task Title (optional)</label>
|
||||
<input
|
||||
id="schedule-task-title"
|
||||
type="text"
|
||||
placeholder="e.g. Review weekly dependencies"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-task-description">Task Description (required)</label>
|
||||
<textarea
|
||||
id="schedule-task-description"
|
||||
placeholder="e.g. Check all npm dependencies for security vulnerabilities"
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
rows={4}
|
||||
aria-invalid={!!errors.taskDescription}
|
||||
aria-describedby={errors.taskDescription ? taskDescriptionErrorId : undefined}
|
||||
/>
|
||||
{errors.taskDescription ? (
|
||||
<small id={taskDescriptionErrorId} className="field-error">{errors.taskDescription}</small>
|
||||
) : (
|
||||
<small>Describes the task that will be created.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-task-column">Target Column</label>
|
||||
<select
|
||||
id="schedule-task-column"
|
||||
value={taskColumn}
|
||||
onChange={(e) => setTaskColumn(e.target.value)}
|
||||
>
|
||||
<option value="triage">Triage</option>
|
||||
<option value="todo">To Do</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-task-model">Executor Model (optional)</label>
|
||||
<CustomModelDropdown
|
||||
id="schedule-task-model"
|
||||
label="Executor Model"
|
||||
models={models}
|
||||
value={modelValue}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Use default"
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
{modelsError && <small className="field-error">{modelsError}</small>}
|
||||
{errors.model ? (
|
||||
<small id={taskModelErrorId} className="field-error">{errors.model}</small>
|
||||
) : (
|
||||
<small>AI model used to execute the created task. Uses default if not selected.</small>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock("lucide-react", () => ({
|
||||
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>,
|
||||
ListPlus: () => <span data-testid="icon-list-plus">➕</span>,
|
||||
}));
|
||||
|
||||
// Mock @fusion/core to provide type-only exports (no runtime values needed)
|
||||
@@ -741,4 +741,204 @@ describe("ScheduleForm", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("simple mode create-task", () => {
|
||||
it("shows Create Task radio button in simple mode", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByRole("radio", { name: "Create Task" })).toBeDefined();
|
||||
expect(screen.getByRole("radio", { name: "Command" })).toBeDefined();
|
||||
expect(screen.getByRole("radio", { name: "AI Prompt" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows task creation fields when Create Task type is selected", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Click Create Task radio
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
|
||||
|
||||
// Create Task radio should be checked
|
||||
expect(screen.getByRole("radio", { name: "Create Task" })).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Task title input should be visible
|
||||
expect(screen.getByLabelText("Task Title (optional)")).toBeDefined();
|
||||
|
||||
// Task description textarea should be visible
|
||||
expect(screen.getByLabelText("Task Description (required)")).toBeDefined();
|
||||
|
||||
// Target column select should be visible
|
||||
expect(screen.getByLabelText("Target Column")).toBeDefined();
|
||||
|
||||
// Executor model dropdown should be visible
|
||||
expect(screen.getByTestId("model-dropdown")).toBeDefined();
|
||||
|
||||
// Command input should NOT be visible
|
||||
expect(screen.queryByLabelText("Command")).toBeNull();
|
||||
|
||||
// Prompt textarea should NOT be visible
|
||||
expect(screen.queryByLabelText("Prompt")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows validation error when task description is empty on submit", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Fill name
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
|
||||
|
||||
// Switch to Create Task mode
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
|
||||
|
||||
// Submit without task description
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
|
||||
// Should show description validation error
|
||||
expect(screen.getByText("Task description is required")).toBeDefined();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits single create-task step when simple mode uses Create Task", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Fill name
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
|
||||
|
||||
// Switch to Create Task mode
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
|
||||
|
||||
// Fill task description
|
||||
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
|
||||
target: { value: "Check npm dependencies for security vulnerabilities" }
|
||||
});
|
||||
|
||||
// Select target column (triage is default)
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Create Task Schedule",
|
||||
command: "",
|
||||
steps: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "create-task",
|
||||
name: "Create Task Schedule",
|
||||
taskDescription: "Check npm dependencies for security vulnerabilities",
|
||||
taskColumn: "triage",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits with task title and model when provided", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Fill name
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
|
||||
|
||||
// Switch to Create Task mode
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
|
||||
|
||||
// Fill task title
|
||||
fireEvent.change(screen.getByLabelText("Task Title (optional)"), {
|
||||
target: { value: "Review Dependencies" }
|
||||
});
|
||||
|
||||
// Fill task description
|
||||
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
|
||||
target: { value: "Check npm dependencies for security vulnerabilities" }
|
||||
});
|
||||
|
||||
// Select To Do column
|
||||
fireEvent.change(screen.getByLabelText("Target Column"), { target: { value: "todo" } });
|
||||
|
||||
// Submit - model is optional
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
steps: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "create-task",
|
||||
taskTitle: "Review Dependencies",
|
||||
taskDescription: "Check npm dependencies for security vulnerabilities",
|
||||
taskColumn: "todo",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("restores Create Task simple type when editing schedule with single create-task step", () => {
|
||||
const schedule = makeSchedule({
|
||||
steps: [
|
||||
{
|
||||
id: "step-1",
|
||||
type: "create-task",
|
||||
name: "Create Task Schedule",
|
||||
taskTitle: "Review Dependencies",
|
||||
taskDescription: "Check npm dependencies for security vulnerabilities",
|
||||
taskColumn: "todo",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
],
|
||||
command: "",
|
||||
});
|
||||
|
||||
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Create Task radio should be selected
|
||||
expect(screen.getByRole("radio", { name: "Create Task" })).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Task title should be populated
|
||||
expect(screen.getByLabelText("Task Title (optional)")).toHaveProperty("value", "Review Dependencies");
|
||||
|
||||
// Task description should be populated
|
||||
expect(screen.getByLabelText("Task Description (required)")).toHaveProperty("value", "Check npm dependencies for security vulnerabilities");
|
||||
|
||||
// Target column should be To Do
|
||||
expect(screen.getByLabelText("Target Column")).toHaveProperty("value", "todo");
|
||||
|
||||
// Command input should not be visible
|
||||
expect(screen.queryByLabelText("Command")).toBeNull();
|
||||
|
||||
// Prompt textarea should not be visible
|
||||
expect(screen.queryByLabelText("Prompt")).toBeNull();
|
||||
});
|
||||
|
||||
it("can switch between all three simple types without losing form state", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
|
||||
// Fill in Command mode
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Test Schedule" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
|
||||
|
||||
// Switch to AI Prompt mode
|
||||
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
|
||||
|
||||
// Enter prompt
|
||||
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize this" } });
|
||||
|
||||
// Switch to Create Task mode
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
|
||||
|
||||
// Enter task description
|
||||
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
|
||||
target: { value: "Check dependencies" }
|
||||
});
|
||||
|
||||
// Switch back to AI Prompt mode - prompt should be preserved
|
||||
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
|
||||
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
|
||||
|
||||
// Switch back to Create Task mode - description should be preserved
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
|
||||
expect(screen.getByLabelText("Task Description (required)")).toHaveProperty("value", "Check dependencies");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user