feat(FN-835): add workflow step execution mode and improve step editor UX
- Add executionMode field to WorkflowStep type with 'agent' | 'api' options and validation - Extend TaskStore CRUD with execution mode support and validation for prompt/description - Add API validation layer in dashboard routes for workflow step create/update/delete - Enhance WorkflowStepManager component with inline editing, validation feedback, and improved UX - Add comprehensive tests for store CRUD validation and API route validation - Clean up dashboard styles by removing unused CSS rules and dead test assertions
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { WorkflowStep, WorkflowStepInput } from "@fusion/core";
|
||||
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflowSteps,
|
||||
createWorkflowStep,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
refineWorkflowStepPrompt,
|
||||
fetchWorkflowStepTemplates,
|
||||
createWorkflowStepFromTemplate,
|
||||
fetchScripts,
|
||||
type WorkflowStepTemplate,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
BookOpen,
|
||||
Terminal,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
interface WorkflowStepManagerProps {
|
||||
@@ -38,7 +41,9 @@ interface WorkflowStepManagerProps {
|
||||
interface StepFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
mode: WorkflowStepMode;
|
||||
prompt: string;
|
||||
scriptName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -47,7 +52,9 @@ type TabId = "my-steps" | "templates";
|
||||
const EMPTY_FORM: StepFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
mode: "prompt",
|
||||
prompt: "",
|
||||
scriptName: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -94,6 +101,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
const [refining, setRefining] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [addingTemplateId, setAddingTemplateId] = useState<string | null>(null);
|
||||
const [availableScripts, setAvailableScripts] = useState<Record<string, string>>({});
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
@@ -107,6 +115,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const loadScripts = useCallback(async () => {
|
||||
try {
|
||||
const scripts = await fetchScripts(projectId);
|
||||
setAvailableScripts(scripts || {});
|
||||
} catch {
|
||||
// Silently ignore — scripts are optional
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
setTemplatesLoading(true);
|
||||
@@ -123,8 +140,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
if (isOpen) {
|
||||
loadSteps();
|
||||
loadTemplates();
|
||||
loadScripts();
|
||||
}
|
||||
}, [isOpen, loadSteps, loadTemplates]);
|
||||
}, [isOpen, loadSteps, loadTemplates, loadScripts]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
@@ -138,7 +156,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
setForm({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
mode: step.mode || "prompt",
|
||||
prompt: step.prompt,
|
||||
scriptName: step.scriptName || "",
|
||||
enabled: step.enabled,
|
||||
});
|
||||
}, []);
|
||||
@@ -161,7 +181,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
mode: form.mode,
|
||||
prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined,
|
||||
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
await createWorkflowStep(input, projectId);
|
||||
@@ -170,7 +192,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
await updateWorkflowStep(editingId, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt,
|
||||
mode: form.mode,
|
||||
prompt: form.mode === "prompt" ? form.prompt : "",
|
||||
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
|
||||
enabled: form.enabled,
|
||||
}, projectId);
|
||||
addToast("Workflow step updated", "success");
|
||||
@@ -204,6 +228,8 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
|
||||
const handleRefine = useCallback(async () => {
|
||||
if (!editingId && !isCreating) return;
|
||||
// Refine only works for prompt mode
|
||||
if (form.mode !== "prompt") return;
|
||||
|
||||
// For new steps being created, we need to save first then refine
|
||||
if (isCreating) {
|
||||
@@ -217,6 +243,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
mode: "prompt",
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
@@ -407,6 +434,21 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
>
|
||||
{step.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background: (step.mode || "prompt") === "script"
|
||||
? "rgba(168, 85, 247, 0.15)"
|
||||
: "rgba(59, 130, 246, 0.15)",
|
||||
color: (step.mode || "prompt") === "script"
|
||||
? "#a855f7"
|
||||
: "#3b82f6",
|
||||
}}
|
||||
>
|
||||
{(step.mode || "prompt") === "script" ? "Script" : "AI Prompt"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -673,61 +715,169 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
{/* Mode Selector */}
|
||||
<div>
|
||||
<div
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||
Agent Prompt
|
||||
</label>
|
||||
Execution Mode
|
||||
</label>
|
||||
<div
|
||||
style={{ display: "flex", gap: "8px" }}
|
||||
data-testid="workflow-step-mode-selector"
|
||||
>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleRefine}
|
||||
disabled={!form.description.trim() || refining}
|
||||
title="Refine with AI"
|
||||
aria-label="Refine prompt with AI"
|
||||
className={`btn ${form.mode === "prompt" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, mode: "prompt", scriptName: "" }))}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
gap: "6px",
|
||||
fontSize: "12px",
|
||||
padding: "6px 12px",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
data-testid="refine-btn"
|
||||
data-testid="mode-prompt"
|
||||
>
|
||||
{refining ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Sparkles size={12} />
|
||||
)}
|
||||
<span style={{ fontSize: "11px" }}>Refine with AI</span>
|
||||
<MessageSquare size={14} />
|
||||
AI Prompt
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${form.mode === "script" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, mode: "script", prompt: "" }))}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
fontSize: "12px",
|
||||
padding: "6px 12px",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
data-testid="mode-script"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
Run Script
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={form.prompt}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
|
||||
placeholder="Leave empty to use AI refinement"
|
||||
rows={6}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "monospace",
|
||||
resize: "vertical",
|
||||
}}
|
||||
data-testid="workflow-step-prompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt (AI mode only) */}
|
||||
{form.mode === "prompt" && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||
Agent Prompt
|
||||
</label>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleRefine}
|
||||
disabled={!form.description.trim() || refining}
|
||||
title="Refine with AI"
|
||||
aria-label="Refine prompt with AI"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
data-testid="refine-btn"
|
||||
>
|
||||
{refining ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Sparkles size={12} />
|
||||
)}
|
||||
<span style={{ fontSize: "11px" }}>Refine with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={form.prompt}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
|
||||
placeholder="Leave empty to use AI refinement"
|
||||
rows={6}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "monospace",
|
||||
resize: "vertical",
|
||||
}}
|
||||
data-testid="workflow-step-prompt"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script selector (script mode only) */}
|
||||
{form.mode === "script" && (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Script
|
||||
</label>
|
||||
{Object.keys(availableScripts).length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-tertiary)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
data-testid="no-scripts-message"
|
||||
>
|
||||
No scripts configured. Add scripts in Settings → Scripts first.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={form.scriptName}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, scriptName: e.target.value }))}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
data-testid="workflow-step-script-select"
|
||||
>
|
||||
<option value="">Select a script…</option>
|
||||
{Object.entries(availableScripts).map(([name, command]) => (
|
||||
<option key={name} value={name}>
|
||||
{name} ({command})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<label
|
||||
style={{
|
||||
@@ -762,7 +912,12 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.description.trim()}
|
||||
disabled={
|
||||
saving ||
|
||||
!form.name.trim() ||
|
||||
!form.description.trim() ||
|
||||
(form.mode === "script" && !form.scriptName.trim())
|
||||
}
|
||||
data-testid="save-workflow-step"
|
||||
>
|
||||
{saving ? "Saving..." : isCreating ? "Create" : "Save"}
|
||||
|
||||
@@ -8,6 +8,7 @@ const mockSteps: WorkflowStep[] = [
|
||||
id: "WS-001",
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs have documentation",
|
||||
mode: "prompt",
|
||||
prompt: "Review the task changes and verify docs.",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -17,6 +18,7 @@ const mockSteps: WorkflowStep[] = [
|
||||
id: "WS-002",
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass",
|
||||
mode: "prompt",
|
||||
prompt: "Execute the test suite.",
|
||||
enabled: false,
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
@@ -30,6 +32,7 @@ vi.mock("../../api", () => ({
|
||||
id: "WS-003",
|
||||
name: "New Step",
|
||||
description: "New description",
|
||||
mode: "prompt",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-03T00:00:00.000Z",
|
||||
@@ -45,6 +48,7 @@ vi.mock("../../api", () => ({
|
||||
prompt: "AI-generated detailed prompt",
|
||||
workflowStep: { ...mockSteps[0], prompt: "AI-generated detailed prompt" },
|
||||
})),
|
||||
fetchScripts: vi.fn(() => Promise.resolve({ test: "pnpm test", lint: "pnpm lint" })),
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -54,6 +58,7 @@ import {
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
} from "../../api";
|
||||
import { fetchScripts } from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
@@ -133,7 +138,9 @@ describe("WorkflowStepManager", () => {
|
||||
expect(createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "New Step",
|
||||
description: "New description",
|
||||
mode: "prompt",
|
||||
prompt: undefined,
|
||||
scriptName: undefined,
|
||||
enabled: true,
|
||||
}, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
|
||||
@@ -241,4 +248,113 @@ describe("WorkflowStepManager", () => {
|
||||
expect(screen.getByText("Disabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows AI Prompt mode badge for prompt steps", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("AI Prompt")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Script mode badge for script steps", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" },
|
||||
]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Script")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mode selector when creating a new step", 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"));
|
||||
|
||||
expect(screen.getByTestId("workflow-step-mode-selector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mode-prompt")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mode-script")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows prompt field in prompt mode and script select in script mode", 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"));
|
||||
|
||||
// Default is prompt mode — should show prompt field
|
||||
expect(screen.getByTestId("workflow-step-prompt")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflow-step-script-select")).not.toBeInTheDocument();
|
||||
|
||||
// Switch to script mode
|
||||
fireEvent.click(screen.getByTestId("mode-script"));
|
||||
|
||||
expect(screen.queryByTestId("workflow-step-prompt")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-step-script-select")).toBeInTheDocument();
|
||||
|
||||
// Switch back to prompt mode
|
||||
fireEvent.click(screen.getByTestId("mode-prompt"));
|
||||
|
||||
expect(screen.getByTestId("workflow-step-prompt")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflow-step-script-select")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads script name when editing a script-mode step", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce([{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" }])
|
||||
.mockResolvedValueOnce([{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" }]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Documentation Review"));
|
||||
|
||||
// Script mode should be selected
|
||||
const scriptSelect = screen.getByTestId("workflow-step-script-select") as HTMLSelectElement;
|
||||
expect(scriptSelect.value).toBe("test");
|
||||
});
|
||||
|
||||
it("disables save when script mode is selected without a script name", 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 nameInput = screen.getByTestId("workflow-step-name");
|
||||
const descInput = screen.getByTestId("workflow-step-description");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "Test" } });
|
||||
fireEvent.change(descInput, { target: { value: "Test desc" } });
|
||||
|
||||
// Switch to script mode
|
||||
fireEvent.click(screen.getByTestId("mode-script"));
|
||||
|
||||
// Save should be disabled (no script selected)
|
||||
const saveBtn = screen.getByTestId("save-workflow-step") as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user