FN-5716: replace base-branch input with branch dropdown
Switch task base-branch selection to prefer existing branches while preserving custom entry when needed. - fetch local git branches for TaskForm and sort common integration branches (main/master/trunk/develop) first - render base branch as a dropdown with default and Custom… options, with fallback custom input mode and toggle back to dropdown - update TaskForm/NewTaskModal tests for branch loading, dropdown ordering, unknown-branch fallback, and fetch failure behavior - document the new branch dropdown + custom fallback behavior in dashboard guide branch planning rules Files changed: docs/dashboard-guide.md | 4 +- packages/dashboard/app/components/TaskForm.tsx | 100 +++++++++++++++++++-- .../app/components/__tests__/NewTaskModal.test.tsx | 1 + .../app/components/__tests__/TaskForm.test.tsx | 66 +++++++++++++- 4 files changed, 156 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-5716 Fusion-Task-Lineage: 0ed72020-d8a0-4675-92d3-9476f53a173a
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
@@ -16,6 +16,26 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
}
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const COMMON_INTEGRATION_BRANCHES = ["main", "master", "trunk", "develop"];
|
||||
const CUSTOM_BRANCH_OPTION = "__fusion-custom__";
|
||||
const DEFAULT_BRANCH_OPTION = "";
|
||||
|
||||
function sortBranchNames(branches: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const name of COMMON_INTEGRATION_BRANCHES) {
|
||||
if (branches.includes(name) && !seen.has(name)) {
|
||||
ordered.push(name);
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
for (const name of [...branches].sort((a, b) => a.localeCompare(b))) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
ordered.push(name);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Renders a phase badge using shared .phase-badge classes for consistency */
|
||||
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode {
|
||||
@@ -214,6 +234,8 @@ export function TaskForm({
|
||||
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
|
||||
const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
@@ -325,6 +347,18 @@ export function TaskForm({
|
||||
githubTrackingDefaultAppliedRef.current = true;
|
||||
}, [mode, isActive, settings, onGithubTrackingEnabledChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !onBaseBranchChange) return;
|
||||
fetchGitBranches(projectId)
|
||||
.then((branches) => {
|
||||
const names = branches
|
||||
.map((branchInfo) => branchInfo.name)
|
||||
.filter((name): name is string => typeof name === "string" && name.length > 0);
|
||||
setBaseBranchOptions(sortBranchNames(names));
|
||||
})
|
||||
.catch(() => setBaseBranchOptions([]));
|
||||
}, [isActive, onBaseBranchChange, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
githubTrackingDefaultAppliedRef.current = false;
|
||||
@@ -1010,14 +1044,62 @@ export function TaskForm({
|
||||
{onBaseBranchChange && (
|
||||
<>
|
||||
<label htmlFor="task-base-branch" className="model-select-label">Merge target / base branch</label>
|
||||
<input
|
||||
id="task-base-branch"
|
||||
className="input"
|
||||
value={baseBranch || ""}
|
||||
onChange={(e) => onBaseBranchChange(e.target.value)}
|
||||
placeholder="e.g. main"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{(() => {
|
||||
const currentValue = baseBranch || "";
|
||||
const valueIsKnown = currentValue.length > 0 && baseBranchOptions.includes(currentValue);
|
||||
const isCustomMode = baseBranchCustomMode || (currentValue.length > 0 && !valueIsKnown) || baseBranchOptions.length === 0;
|
||||
if (isCustomMode) {
|
||||
return (
|
||||
<div className="form-inline-group">
|
||||
<input
|
||||
id="task-base-branch"
|
||||
className="input"
|
||||
value={currentValue}
|
||||
onChange={(e) => onBaseBranchChange(e.target.value)}
|
||||
placeholder="e.g. main"
|
||||
disabled={disabled}
|
||||
data-testid="task-base-branch-custom-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => {
|
||||
setBaseBranchCustomMode(false);
|
||||
onBaseBranchChange("");
|
||||
}}
|
||||
disabled={disabled}
|
||||
data-testid="task-base-branch-use-dropdown"
|
||||
>
|
||||
Use dropdown
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
id="task-base-branch"
|
||||
className="select"
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (next === CUSTOM_BRANCH_OPTION) {
|
||||
setBaseBranchCustomMode(true);
|
||||
return;
|
||||
}
|
||||
onBaseBranchChange(next === DEFAULT_BRANCH_OPTION ? "" : next);
|
||||
}}
|
||||
disabled={disabled}
|
||||
data-testid="task-base-branch-select"
|
||||
>
|
||||
<option value={DEFAULT_BRANCH_OPTION}>(default / project branch)</option>
|
||||
{baseBranchOptions.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
<option value={CUSTOM_BRANCH_OPTION}>Custom…</option>
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("../../api", () => ({
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchGitBranches: vi.fn().mockResolvedValue([]),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
|
||||
refineText: vi.fn(),
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("../../api", () => ({
|
||||
refineText: vi.fn().mockResolvedValue("Refined text"),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchGitBranches: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -127,8 +128,10 @@ globalThis.URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
describe("TaskForm", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { fetchGitBranches } = await import("../../api");
|
||||
vi.mocked(fetchGitBranches).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders description field with AI refine button when text is present", () => {
|
||||
@@ -222,7 +225,7 @@ describe("TaskForm", () => {
|
||||
expect(onPriorityChange).toHaveBeenCalledWith("urgent");
|
||||
});
|
||||
|
||||
it("renders working and base branch inputs when branch callbacks are provided", () => {
|
||||
it("renders working branch input and base branch custom input when no branch options are available", () => {
|
||||
renderTaskForm({
|
||||
branch: "feature/fn-3422",
|
||||
baseBranch: "main",
|
||||
@@ -233,7 +236,7 @@ describe("TaskForm", () => {
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
expect(screen.getByLabelText("Working branch")).toHaveValue("feature/fn-3422");
|
||||
expect(screen.getByLabelText("Merge target / base branch")).toHaveValue("main");
|
||||
expect(screen.getByTestId("task-base-branch-custom-input")).toHaveValue("main");
|
||||
});
|
||||
|
||||
it("calls branch change handlers and supports explicit clearing", () => {
|
||||
@@ -250,7 +253,7 @@ describe("TaskForm", () => {
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: "feature/new" } });
|
||||
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: "" } });
|
||||
fireEvent.change(screen.getByTestId("task-base-branch-custom-input"), { target: { value: "" } });
|
||||
|
||||
expect(onBranchChange).toHaveBeenCalledWith("feature/new");
|
||||
expect(onBaseBranchChange).toHaveBeenCalledWith("");
|
||||
@@ -273,6 +276,61 @@ describe("TaskForm", () => {
|
||||
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("renders base branch dropdown options sorted with common integration branches first", async () => {
|
||||
const { fetchGitBranches } = await import("../../api");
|
||||
vi.mocked(fetchGitBranches).mockResolvedValueOnce([
|
||||
{ name: "release" },
|
||||
{ name: "develop" },
|
||||
{ name: "feature/foo" },
|
||||
{ name: "main" },
|
||||
{ name: "main" },
|
||||
{ name: "trunk" },
|
||||
] as any);
|
||||
|
||||
const onBaseBranchChange = vi.fn();
|
||||
renderTaskForm({ onBaseBranchChange });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
const select = await screen.findByTestId("task-base-branch-select");
|
||||
const optionValues = Array.from((select as HTMLSelectElement).options).map((option) => option.value);
|
||||
expect(optionValues).toEqual(["", "main", "trunk", "develop", "feature/foo", "release", "__fusion-custom__"]);
|
||||
|
||||
fireEvent.change(select, { target: { value: "develop" } });
|
||||
expect(onBaseBranchChange).toHaveBeenCalledWith("develop");
|
||||
|
||||
fireEvent.change(select, { target: { value: "__fusion-custom__" } });
|
||||
expect(screen.getByTestId("task-base-branch-custom-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("defaults to custom mode for unknown base branch and supports switching back to dropdown", async () => {
|
||||
const { fetchGitBranches } = await import("../../api");
|
||||
vi.mocked(fetchGitBranches).mockResolvedValueOnce([{ name: "main" }, { name: "develop" }] as any);
|
||||
|
||||
const onBaseBranchChange = vi.fn();
|
||||
renderTaskForm({
|
||||
baseBranch: "release/candidate",
|
||||
onBaseBranchChange,
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
expect(await screen.findByTestId("task-base-branch-custom-input")).toHaveValue("release/candidate");
|
||||
fireEvent.click(screen.getByTestId("task-base-branch-use-dropdown"));
|
||||
|
||||
expect(onBaseBranchChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("keeps base branch entry available when branch loading fails", async () => {
|
||||
const { fetchGitBranches } = await import("../../api");
|
||||
vi.mocked(fetchGitBranches).mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
renderTaskForm({ onBaseBranchChange: vi.fn() });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-base-branch-custom-input")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches and stores favoriteModels from fetchModels response", async () => {
|
||||
const { fetchModels } = await import("../../api");
|
||||
vi.mocked(fetchModels).mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user