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

This commit is contained in:
gsxdsm
2026-04-16 08:08:06 -07:00
parent 68b1651e0b
commit b1c9f29880
2 changed files with 346 additions and 9 deletions

View File

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

View File

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