feat(dashboard): wire optional-steps dropdown into the full New Task modal
This commit is contained in:
@@ -61,6 +61,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
// `null` = explicit "No workflow", `string` = a specific workflow. Materialized
|
||||
// atomically at create time via the `workflowId` create parameter.
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
// Optional workflow steps the user opted into; TaskForm fetches + seeds these
|
||||
// from the selected workflow's defaultOn and lifts the enabled set up here.
|
||||
const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]);
|
||||
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
@@ -241,6 +244,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
// - null → explicit "No workflow" (store skips default materialization)
|
||||
// - string → that workflow, materialized atomically at create time.
|
||||
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
|
||||
// Optional steps the user toggled on (omit when none so the store keeps its
|
||||
// default materialization behavior).
|
||||
...(enabledWorkflowSteps.length ? { enabledWorkflowSteps } : {}),
|
||||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
@@ -318,7 +324,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, enabledWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -506,6 +512,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onSelectedPresetIdChange={setSelectedPresetId}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
onWorkflowIdChange={setSelectedWorkflowId}
|
||||
enabledWorkflowSteps={enabledWorkflowSteps}
|
||||
onEnabledWorkflowStepsChange={setEnabledWorkflowSteps}
|
||||
pendingImages={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
tasks={tasks}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition } from "@fusion/core";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition, type ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { fetchModels, fetchSettings, fetchWorkflows, fetchWorkflowOptionalSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
@@ -99,6 +100,11 @@ export interface TaskFormProps {
|
||||
// edit-mode workflow management lives in the task detail Workflow tab.
|
||||
selectedWorkflowId?: string | null;
|
||||
onWorkflowIdChange?: (workflowId: string | null) => void;
|
||||
// Optional workflow steps the task can opt into. TaskForm fetches + seeds these
|
||||
// from the selected workflow's `defaultOn` and lifts the enabled set to the
|
||||
// parent (which puts it in the create payload). Only active in create mode.
|
||||
enabledWorkflowSteps?: string[];
|
||||
onEnabledWorkflowStepsChange?: (ids: string[]) => void;
|
||||
|
||||
// Attachments
|
||||
pendingImages: PendingImage[];
|
||||
@@ -176,6 +182,8 @@ export function TaskForm({
|
||||
onSelectedPresetIdChange,
|
||||
selectedWorkflowId,
|
||||
onWorkflowIdChange,
|
||||
enabledWorkflowSteps,
|
||||
onEnabledWorkflowStepsChange,
|
||||
pendingImages,
|
||||
onImagesChange,
|
||||
tasks,
|
||||
@@ -235,6 +243,8 @@ export function TaskForm({
|
||||
// U6/R3: full workflow definitions for the picker (fragments excluded below).
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [workflowsLoading, setWorkflowsLoading] = useState(false);
|
||||
const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
|
||||
const [optionalStepsLoading, setOptionalStepsLoading] = useState(false);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
|
||||
const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false);
|
||||
@@ -285,6 +295,56 @@ export function TaskForm({
|
||||
.catch(() => setGlobalSettings(null));
|
||||
}, [isActive, projectId, onWorkflowIdChange]);
|
||||
|
||||
// Optional workflow steps for the currently-selected workflow (create mode only).
|
||||
// `null` selection ("No workflow") → no steps; `undefined` → project default.
|
||||
const effectiveOptionalWorkflowId =
|
||||
selectedWorkflowId === null
|
||||
? null
|
||||
: (selectedWorkflowId ?? settings?.defaultWorkflowId ?? null);
|
||||
useEffect(() => {
|
||||
if (!onWorkflowIdChange) return; // edit mode: optional steps are managed in the Workflow tab.
|
||||
let cancelled = false;
|
||||
setOptionalSteps([]);
|
||||
if (!effectiveOptionalWorkflowId) {
|
||||
onEnabledWorkflowStepsChange?.([]);
|
||||
return;
|
||||
}
|
||||
setOptionalStepsLoading(true);
|
||||
fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId)
|
||||
.then((steps) => {
|
||||
if (cancelled) return;
|
||||
setOptionalSteps(steps);
|
||||
// Re-seed the enabled set from each step's defaultOn on every workflow change.
|
||||
onEnabledWorkflowStepsChange?.(steps.filter((s) => s.defaultOn).map((s) => s.templateId));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setOptionalSteps([]);
|
||||
onEnabledWorkflowStepsChange?.([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setOptionalStepsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// onEnabledWorkflowStepsChange intentionally omitted: a new identity each render
|
||||
// must not re-trigger the fetch/re-seed (would clobber user toggles).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
|
||||
|
||||
const enabledOptionalStepIds = enabledWorkflowSteps ?? [];
|
||||
const toggleOptionalStep = useCallback(
|
||||
(templateId: string) => {
|
||||
const current = enabledWorkflowSteps ?? [];
|
||||
const next = current.includes(templateId)
|
||||
? current.filter((id) => id !== templateId)
|
||||
: [...current, templateId];
|
||||
onEnabledWorkflowStepsChange?.(next);
|
||||
},
|
||||
[enabledWorkflowSteps, onEnabledWorkflowStepsChange],
|
||||
);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings);
|
||||
@@ -1340,6 +1400,23 @@ export function TaskForm({
|
||||
<small className="workflow-select-help" data-testid="task-workflow-help">
|
||||
{t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")}
|
||||
</small>
|
||||
{optionalStepsLoading ? (
|
||||
<small className="workflow-optional-steps-loading" data-testid="task-optional-steps-loading">
|
||||
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
|
||||
</small>
|
||||
) : (
|
||||
optionalSteps.length > 0 && (
|
||||
<div className="task-form-optional-steps" data-testid="task-form-optional-steps">
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={optionalSteps}
|
||||
enabledIds={enabledOptionalStepIds}
|
||||
onToggle={toggleOptionalStep}
|
||||
disabled={disabled}
|
||||
triggerTestId="task-optional-steps-trigger"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("../../api", () => ({
|
||||
// U6/R3: TaskForm's picker fetches whole workflows; the per-step
|
||||
// fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchGitBranches: vi.fn().mockResolvedValue([]),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
@@ -239,6 +240,89 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional workflow steps (U4)", () => {
|
||||
const WF = {
|
||||
id: "wf-x",
|
||||
name: "Custom",
|
||||
kind: "workflow" as const,
|
||||
description: "",
|
||||
ir: { version: "v1" as const, name: "Custom", nodes: [], edges: [] },
|
||||
layout: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const STEP = {
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
phase: "pre-merge" as const,
|
||||
defaultOn: false,
|
||||
};
|
||||
|
||||
it("includes a toggled-on optional step in the create payload", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), {
|
||||
target: { value: "Verify the login page" },
|
||||
});
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
const trigger = await screen.findByTestId("task-optional-steps-trigger");
|
||||
expect(trigger).toHaveTextContent("Steps: none");
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds defaultOn steps as pre-enabled and submits them without toggling", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
const trigger = await screen.findByTestId("task-optional-steps-trigger");
|
||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no dropdown and omits enabledWorkflowSteps for 'No workflow'", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
||||
// "No workflow" → null selection → no optional-steps fetch, no dropdown.
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "__none__" } });
|
||||
|
||||
expect(screen.queryByTestId("task-optional-steps-trigger")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(props.onCreateTask).mock.calls.at(-1)?.[0];
|
||||
expect(call).not.toHaveProperty("enabledWorkflowSteps");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("submits project-default branch selection by default", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("../../api", () => ({
|
||||
}),
|
||||
// U6/R3: TaskForm now fetches whole workflows (not steps) for the picker.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
refineText: vi.fn().mockResolvedValue("Refined text"),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
|
||||
Reference in New Issue
Block a user