feat(FN-883): add workflow step default-on support with task creation opt-out
- Add defaultOn field to WorkflowStep model, API endpoints (GET/POST/PATCH), and WorkflowStepManager UI - Add skip-default-steps checkbox to TaskForm and NewTaskModal for explicit opt-out of default workflow steps - Pre-select default workflow steps on new task creation forms automatically - Add CSS styling for skip-default-steps toggle row - Add comprehensive tests for WorkflowStepManager, TaskForm, and NewTaskModal components - Add route tests for PATCH workflow-step endpoint and default selection logic - Update README and task-structure reference docs
This commit is contained in:
@@ -126,6 +126,7 @@ What the task should accomplish.
|
||||
"name": "Documentation Review",
|
||||
"prompt": "Review the task changes...",
|
||||
"enabled": true,
|
||||
"defaultOn": true,
|
||||
"modelProvider": "anthropic",
|
||||
"modelId": "claude-sonnet-4-5"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
|
||||
|
||||
// Handler for workflow step changes that detects explicit user interaction
|
||||
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
|
||||
setWorkflowStepsExplicitlySet(true);
|
||||
setSelectedWorkflowSteps(steps);
|
||||
}, []);
|
||||
|
||||
// Callback when defaultOn steps are auto-applied by TaskForm
|
||||
const handleDefaultOnApplied = useCallback(() => {
|
||||
// defaultOn auto-selection is not "explicit" user interaction
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
}, []);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
@@ -54,6 +67,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowSteps([]);
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -72,7 +86,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
enabledWorkflowSteps: selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : undefined,
|
||||
// When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults.
|
||||
// When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults.
|
||||
enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined,
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
|
||||
@@ -105,6 +121,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowSteps([]);
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
@@ -113,7 +130,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -154,7 +171,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
selectedPresetId={selectedPresetId}
|
||||
onSelectedPresetIdChange={setSelectedPresetId}
|
||||
selectedWorkflowSteps={selectedWorkflowSteps}
|
||||
onWorkflowStepsChange={setSelectedWorkflowSteps}
|
||||
onWorkflowStepsChange={handleWorkflowStepsChange}
|
||||
onDefaultOnApplied={handleDefaultOnApplied}
|
||||
pendingImages={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
tasks={tasks}
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface TaskFormProps {
|
||||
// Workflow steps
|
||||
selectedWorkflowSteps: string[];
|
||||
onWorkflowStepsChange: (steps: string[]) => void;
|
||||
/** Callback fired when defaultOn steps have been preselected (create mode). Parent can use this to distinguish "no selection yet" from "user explicitly cleared". */
|
||||
onDefaultOnApplied?: (stepIds: string[]) => void;
|
||||
|
||||
// Attachments
|
||||
pendingImages: PendingImage[];
|
||||
@@ -75,6 +77,7 @@ export function TaskForm({
|
||||
onSelectedPresetIdChange,
|
||||
selectedWorkflowSteps,
|
||||
onWorkflowStepsChange,
|
||||
onDefaultOnApplied,
|
||||
pendingImages,
|
||||
onImagesChange,
|
||||
tasks,
|
||||
@@ -141,6 +144,29 @@ export function TaskForm({
|
||||
}
|
||||
}, [isActive, settings, availablePresets, mode]);
|
||||
|
||||
// Auto-select defaultOn workflow steps (create mode, once per activation)
|
||||
const defaultOnAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "create" || !isActive) return;
|
||||
if (defaultOnAppliedRef.current) return;
|
||||
if (workflowSteps.length === 0) return;
|
||||
|
||||
const defaultOnSteps = workflowSteps.filter((s) => s.defaultOn);
|
||||
if (defaultOnSteps.length === 0) return;
|
||||
|
||||
defaultOnAppliedRef.current = true;
|
||||
const stepIds = defaultOnSteps.map((s) => s.id);
|
||||
onWorkflowStepsChange(stepIds);
|
||||
onDefaultOnApplied?.(stepIds);
|
||||
}, [mode, isActive, workflowSteps]);
|
||||
|
||||
// Reset defaultOn tracking when form deactivates
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
defaultOnAppliedRef.current = false;
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
// Auto-focus description (create) or title (edit) when active
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
@@ -49,6 +49,7 @@ interface StepFormData {
|
||||
prompt: string;
|
||||
scriptName: string;
|
||||
enabled: boolean;
|
||||
defaultOn: boolean;
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
}
|
||||
@@ -63,6 +64,7 @@ const EMPTY_FORM: StepFormData = {
|
||||
prompt: "",
|
||||
scriptName: "",
|
||||
enabled: true,
|
||||
defaultOn: false,
|
||||
modelProvider: "",
|
||||
modelId: "",
|
||||
};
|
||||
@@ -194,6 +196,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
prompt: step.prompt,
|
||||
scriptName: step.scriptName || "",
|
||||
enabled: step.enabled,
|
||||
defaultOn: step.defaultOn || false,
|
||||
modelProvider: step.modelProvider || "",
|
||||
modelId: step.modelId || "",
|
||||
});
|
||||
@@ -229,6 +232,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined,
|
||||
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
|
||||
enabled: form.enabled,
|
||||
defaultOn: form.defaultOn || undefined,
|
||||
...modelFields,
|
||||
};
|
||||
await createWorkflowStep(input, projectId);
|
||||
@@ -242,6 +246,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
prompt: form.mode === "prompt" ? form.prompt : "",
|
||||
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
|
||||
enabled: form.enabled,
|
||||
defaultOn: form.defaultOn,
|
||||
...modelFields,
|
||||
}, projectId);
|
||||
addToast("Workflow step updated", "success");
|
||||
@@ -297,6 +302,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
mode: "prompt",
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
defaultOn: form.defaultOn || undefined,
|
||||
...modelFields,
|
||||
};
|
||||
const created = await createWorkflowStep(input, projectId);
|
||||
@@ -429,6 +435,11 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
<span className={`wfm-badge ${(step.phase || "pre-merge") === "post-merge" ? "wfm-badge-post-merge" : "wfm-badge-pre-merge"}`}>
|
||||
{(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"}
|
||||
</span>
|
||||
{step.defaultOn && (
|
||||
<span className="wfm-badge wfm-badge-default-on">
|
||||
Default on
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wfm-step-card-desc">
|
||||
{step.description}
|
||||
@@ -744,6 +755,17 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
Enabled (available for selection on new tasks)
|
||||
</label>
|
||||
|
||||
{/* Default on toggle */}
|
||||
<label className="wfm-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.defaultOn}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, defaultOn: e.target.checked }))}
|
||||
data-testid="workflow-step-default-on"
|
||||
/>
|
||||
Default on for new tasks
|
||||
</label>
|
||||
|
||||
{/* Form actions */}
|
||||
<div className="wfm-form-actions">
|
||||
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
|
||||
|
||||
@@ -512,4 +512,100 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// DefaultOn workflow step handling (FN-883)
|
||||
describe("defaultOn workflow step handling", () => {
|
||||
it("sends undefined enabledWorkflowSteps when no defaultOn steps and user hasn't interacted", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Don't interact with workflow steps at all
|
||||
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "No interaction task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends empty array when user explicitly deselects all defaultOn steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Wait for auto-selection to happen
|
||||
await waitFor(() => {
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
// User explicitly deselects the auto-selected step
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Deselected task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends defaultOn step IDs when user doesn't modify the auto-selected steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security", description: "Check security", prompt: "Check", enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Wait for auto-selection to happen
|
||||
await waitFor(() => {
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
// Don't modify the selection — just submit.
|
||||
// Since user hasn't explicitly changed steps, the explicitlySet flag is false,
|
||||
// so the modal sends undefined (backend applies its own defaults)
|
||||
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Auto-selected task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -767,3 +767,136 @@ describe("TaskForm workflow step reordering (FN-836)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskForm defaultOn auto-selection (FN-883)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("auto-selects defaultOn workflow steps in create mode", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
const onDefaultOnApplied = vi.fn();
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
onWorkflowStepsChange,
|
||||
onDefaultOnApplied,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
|
||||
});
|
||||
expect(onDefaultOnApplied).toHaveBeenCalledWith(["WS-001"]);
|
||||
});
|
||||
|
||||
it("does not auto-select workflow steps in edit mode", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({
|
||||
mode: "edit",
|
||||
title: "Test",
|
||||
onTitleChange: vi.fn(),
|
||||
onWorkflowStepsChange,
|
||||
});
|
||||
|
||||
// Wait for workflow steps to load
|
||||
await waitFor(() => {
|
||||
expect(fetchWorkflowSteps).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT have called onWorkflowStepsChange with defaults
|
||||
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-apply defaults after user changes workflow steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
let currentSteps: string[] = [];
|
||||
const onWorkflowStepsChange = vi.fn((steps: string[]) => {
|
||||
currentSteps = steps;
|
||||
});
|
||||
|
||||
const { rerender } = renderTaskForm({
|
||||
mode: "create",
|
||||
selectedWorkflowSteps: currentSteps,
|
||||
onWorkflowStepsChange,
|
||||
});
|
||||
|
||||
// Wait for auto-selection
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
|
||||
});
|
||||
|
||||
// Simulate parent state update by rerendering with new steps
|
||||
rerender(
|
||||
<TaskForm
|
||||
mode="create"
|
||||
description=""
|
||||
onDescriptionChange={vi.fn()}
|
||||
dependencies={[]}
|
||||
onDependenciesChange={vi.fn()}
|
||||
executorModel=""
|
||||
onExecutorModelChange={vi.fn()}
|
||||
validatorModel=""
|
||||
onValidatorModelChange={vi.fn()}
|
||||
presetMode="default"
|
||||
onPresetModeChange={vi.fn()}
|
||||
selectedPresetId=""
|
||||
onSelectedPresetIdChange={vi.fn()}
|
||||
selectedWorkflowSteps={currentSteps}
|
||||
onWorkflowStepsChange={onWorkflowStepsChange}
|
||||
pendingImages={[]}
|
||||
onImagesChange={vi.fn()}
|
||||
tasks={[]}
|
||||
addToast={vi.fn()}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Clear the mock to track further calls
|
||||
onWorkflowStepsChange.mockClear();
|
||||
|
||||
// Simulate user toggling WS-002 checkbox
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Should have been called with user action (adding WS-002 to existing WS-001)
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002"]);
|
||||
// No additional auto-selection calls
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not auto-select when no steps have defaultOn", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
onWorkflowStepsChange,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWorkflowSteps).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT have called onWorkflowStepsChange
|
||||
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +191,7 @@ describe("WorkflowStepManager", () => {
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: true,
|
||||
defaultOn: undefined,
|
||||
}, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
|
||||
});
|
||||
@@ -454,6 +455,7 @@ describe("WorkflowStepManager", () => {
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: true,
|
||||
defaultOn: undefined,
|
||||
}, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
|
||||
});
|
||||
@@ -490,6 +492,108 @@ describe("WorkflowStepManager", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows defaultOn checkbox in create form", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-workflow-step"));
|
||||
|
||||
const defaultOnCheckbox = screen.getByTestId("workflow-step-default-on") as HTMLInputElement;
|
||||
expect(defaultOnCheckbox).toBeInTheDocument();
|
||||
expect(defaultOnCheckbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("creates workflow step with defaultOn true", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-workflow-step"));
|
||||
|
||||
const nameInput = screen.getByTestId("workflow-step-name");
|
||||
const descInput = screen.getByTestId("workflow-step-description");
|
||||
fireEvent.change(nameInput, { target: { value: "Auto Step" } });
|
||||
fireEvent.change(descInput, { target: { value: "Auto-enabled" } });
|
||||
|
||||
// Check the defaultOn checkbox
|
||||
const defaultOnCheckbox = screen.getByTestId("workflow-step-default-on");
|
||||
fireEvent.click(defaultOnCheckbox);
|
||||
|
||||
fireEvent.click(screen.getByTestId("save-workflow-step"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWorkflowStep).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Auto Step",
|
||||
defaultOn: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("prefills defaultOn when editing a step with defaultOn true", async () => {
|
||||
const stepsWithDefaultOn: WorkflowStep[] = [
|
||||
{ ...mockSteps[0], defaultOn: true },
|
||||
];
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(stepsWithDefaultOn);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Documentation Review"));
|
||||
|
||||
const defaultOnCheckbox = screen.getByTestId("workflow-step-default-on") as HTMLInputElement;
|
||||
expect(defaultOnCheckbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows default-on badge for steps with defaultOn true", async () => {
|
||||
const stepsWithDefaultOn: WorkflowStep[] = [
|
||||
{ ...mockSteps[0], defaultOn: true },
|
||||
{ ...mockSteps[1], defaultOn: false },
|
||||
];
|
||||
vi.mocked(fetchWorkflowSteps).mockReset();
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(stepsWithDefaultOn);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("QA Check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Only the first step should have the "Default on" badge
|
||||
const badges = screen.getAllByText("Default on");
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not show default-on badge when defaultOn is false or undefined", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockReset();
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Default on")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowStepManager theme class structure", () => {
|
||||
|
||||
@@ -12984,6 +12984,11 @@ html .column.drag-over * {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.wfm-badge-default-on {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.wfm-badge-category {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
|
||||
@@ -6313,6 +6313,7 @@ describe("POST /workflow-steps", () => {
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: undefined,
|
||||
defaultOn: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6368,6 +6369,7 @@ describe("POST /workflow-steps", () => {
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: undefined,
|
||||
defaultOn: false,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
@@ -6415,6 +6417,7 @@ describe("POST /workflow-steps", () => {
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: undefined,
|
||||
defaultOn: false,
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
});
|
||||
@@ -6443,6 +6446,7 @@ describe("POST /workflow-steps", () => {
|
||||
prompt: undefined,
|
||||
scriptName: "test",
|
||||
enabled: undefined,
|
||||
defaultOn: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6503,6 +6507,7 @@ describe("POST /workflow-steps", () => {
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: undefined,
|
||||
defaultOn: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6516,6 +6521,55 @@ describe("POST /workflow-steps", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("phase must be");
|
||||
});
|
||||
|
||||
it("creates a workflow step with defaultOn true", async () => {
|
||||
const created = { id: "WS-010", name: "Auto Step", description: "Auto-enabled", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
name: "Auto Step",
|
||||
description: "Auto-enabled",
|
||||
defaultOn: true,
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "Auto Step",
|
||||
description: "Auto-enabled",
|
||||
mode: "prompt",
|
||||
phase: undefined,
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: undefined,
|
||||
defaultOn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults defaultOn to false when not specified", async () => {
|
||||
const created = { id: "WS-011", name: "Manual Step", description: "Manual only", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
name: "Manual Step",
|
||||
description: "Manual only",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createWorkflowStep).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ defaultOn: false })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 when defaultOn is not a boolean", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
name: "Bad Step",
|
||||
description: "Bad defaultOn",
|
||||
defaultOn: "yes",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("defaultOn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /workflow-steps/:id", () => {
|
||||
@@ -6668,6 +6722,39 @@ describe("PATCH /workflow-steps/:id", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("phase must be");
|
||||
});
|
||||
|
||||
it("updates defaultOn to true", async () => {
|
||||
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||||
defaultOn: true,
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: true }));
|
||||
});
|
||||
|
||||
it("updates defaultOn to false", async () => {
|
||||
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: false, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
|
||||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||||
defaultOn: false,
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: false }));
|
||||
});
|
||||
|
||||
it("returns 400 when defaultOn is not a boolean in PATCH", async () => {
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||||
defaultOn: "yes",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("defaultOn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /workflow-steps/:id", () => {
|
||||
|
||||
@@ -6001,7 +6001,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.post("/workflow-steps", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { name, description, mode, phase, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
|
||||
const { name, description, mode, phase, prompt, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
@@ -6037,6 +6037,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
res.status(400).json({ error: "enabled must be a boolean" });
|
||||
return;
|
||||
}
|
||||
if (defaultOn !== undefined && typeof defaultOn !== "boolean") {
|
||||
res.status(400).json({ error: "defaultOn must be a boolean" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate script mode: scriptName must reference a named script in settings
|
||||
if (resolvedMode === "script") {
|
||||
@@ -6070,6 +6074,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
prompt: prompt?.trim(),
|
||||
scriptName: scriptName?.trim(),
|
||||
enabled,
|
||||
defaultOn: defaultOn === true,
|
||||
modelProvider: modelPair.provider,
|
||||
modelId: modelPair.modelId,
|
||||
});
|
||||
@@ -6089,7 +6094,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.patch("/workflow-steps/:id", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { name, description, mode, phase, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
|
||||
const { name, description, mode, phase, prompt, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name !== undefined) {
|
||||
@@ -6141,6 +6146,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
updates.enabled = enabled;
|
||||
}
|
||||
if (defaultOn !== undefined) {
|
||||
if (typeof defaultOn !== "boolean") {
|
||||
res.status(400).json({ error: "defaultOn must be a boolean" });
|
||||
return;
|
||||
}
|
||||
updates.defaultOn = defaultOn;
|
||||
}
|
||||
|
||||
// Validate script-mode requirements against the resulting state (existing + updates)
|
||||
// This catches cases where an existing script-mode step has its scriptName updated
|
||||
|
||||
Reference in New Issue
Block a user