fix(web/task-create): disable "Add repository" when no other repos available

When the user only has the primary repository in their workspace,
clicking "+ Add repository" used to insert a row whose dropdown was
empty — looking like a bug ("no other repos show up"). The button now
disables itself when there are no remaining workspace repositories to
choose from, with a tooltip explaining why ("All workspace repositories
are already added"). The previous "primary not selected" guard is also
surfaced via tooltip.

- ExtraRepositoryRows computes remainingCount = repos − used (primary
  + already-added extras) and gates the Add button on it.
- New computeAddButtonHint helper returns a per-state title string for
  the disabled cases.
- Tests cover: dropdown shows the unused workspace repos when opened
  (the click path the user reported wasn't actually broken — the gate
  was just missing); Add button disabled when only the primary exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-04-26 01:48:07 +01:00
parent 860f1d1a25
commit ffef4da34a
2 changed files with 173 additions and 1 deletions
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
afterEach(cleanup);
import type { Repository } from "@/lib/types/http";
import type { DialogFormState, ExtraRepositoryRow } from "./task-create-dialog-types";
// Mock branches hook so the BranchSelector inside the row doesn't blow up.
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
useRepositoryBranches: () => ({ branches: [], isLoading: false }),
}));
// Defer the import until after mocks are set up.
async function loadComponent() {
return (await import("./task-create-dialog-extra-repos")).ExtraRepositoryRows;
}
const REPO_FRONT_ID = "repo-front";
const REPO_BACK_ID = "repo-back";
function makeRepo(id: string, name: string): Repository {
return {
id,
workspace_id: "ws-1",
name,
source_type: "local",
local_path: `/repos/${name}`,
default_branch: "main",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Repository;
}
function makeFs(overrides: Partial<DialogFormState>): DialogFormState {
// Only the fields ExtraRepositoryRows actually reads need to be real.
return {
repositoryId: REPO_FRONT_ID,
extraRepositories: [] as ExtraRepositoryRow[],
addExtraRepository: vi.fn(),
removeExtraRepository: vi.fn(),
updateExtraRepository: vi.fn(),
...overrides,
} as unknown as DialogFormState;
}
describe("ExtraRepositoryRows", () => {
it("disables the Add button until a primary repo is selected", async () => {
const ExtraRepositoryRows = await loadComponent();
render(
<ExtraRepositoryRows
fs={makeFs({})}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
isTaskStarted={false}
primarySelected={false}
/>,
);
const btn = screen.getByTestId("add-extra-repository") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it("calls addExtraRepository when the Add button is clicked", async () => {
const ExtraRepositoryRows = await loadComponent();
const fs = makeFs({});
render(
<ExtraRepositoryRows
fs={fs}
// Need a second repo so the Add button is enabled (the primary
// would otherwise be the only choice).
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
isTaskStarted={false}
primarySelected
/>,
);
fireEvent.click(screen.getByTestId("add-extra-repository"));
expect(fs.addExtraRepository).toHaveBeenCalledOnce();
});
it("renders one editable row per extra repository", async () => {
const ExtraRepositoryRows = await loadComponent();
render(
<ExtraRepositoryRows
fs={makeFs({
extraRepositories: [
{ key: "extra-1", repositoryId: "", branch: "" },
{ key: "extra-2", repositoryId: "", branch: "" },
],
})}
repositories={[
makeRepo(REPO_FRONT_ID, "frontend"),
makeRepo(REPO_BACK_ID, "backend"),
makeRepo("repo-shared", "shared"),
]}
isTaskStarted={false}
primarySelected
/>,
);
expect(screen.getAllByTestId("extra-repository-row")).toHaveLength(2);
});
it("hides the section when the task is already started", async () => {
const ExtraRepositoryRows = await loadComponent();
const { container } = render(
<ExtraRepositoryRows
fs={makeFs({
extraRepositories: [{ key: "extra-1", repositoryId: "", branch: "" }],
})}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
isTaskStarted
primarySelected
/>,
);
expect(container.querySelector("[data-testid='extra-repositories']")).toBeNull();
});
it("opening a row's dropdown shows the unused workspace repos", async () => {
const ExtraRepositoryRows = await loadComponent();
render(
<ExtraRepositoryRows
fs={makeFs({
repositoryId: REPO_FRONT_ID,
extraRepositories: [{ key: "extra-1", repositoryId: "", branch: "" }],
})}
repositories={[
makeRepo(REPO_FRONT_ID, "frontend"),
makeRepo(REPO_BACK_ID, "backend"),
makeRepo("repo-shared", "shared"),
]}
isTaskStarted={false}
primarySelected
/>,
);
// Open the row's repository combobox.
const triggers = screen.getAllByTestId("repository-selector");
fireEvent.click(triggers[0]);
// The primary repo (frontend) is excluded; the other two should be offered.
expect(screen.queryByText("backend")).not.toBeNull();
expect(screen.queryByText("shared")).not.toBeNull();
expect(screen.queryByText("frontend")).toBeNull();
});
it("disables the Add button when no other workspace repos are available", async () => {
const ExtraRepositoryRows = await loadComponent();
render(
<ExtraRepositoryRows
fs={makeFs({ repositoryId: "repo-only" })}
repositories={[makeRepo("repo-only", "only")]}
isTaskStarted={false}
primarySelected
/>,
);
const btn = screen.getByTestId("add-extra-repository") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
});
@@ -41,6 +41,14 @@ export function ExtraRepositoryRows({
// Hide the section in started/edit modes where the repo set is fixed.
if (isTaskStarted) return null;
// Count workspace repositories the user could still add: total minus primary
// minus all already-chosen extras. When zero, we disable the Add button so
// the user gets a clear signal instead of a row with an empty dropdown.
const usedIds = collectUsedRepoIds(fs, "");
const remainingCount = repositories.filter((r) => !usedIds.has(r.id)).length;
const canAddMore = primarySelected && remainingCount > 0;
const addButtonHint = computeAddButtonHint(primarySelected, remainingCount);
return (
<div className="space-y-2" data-testid="extra-repositories">
{fs.extraRepositories.map((row) => (
@@ -60,7 +68,8 @@ export function ExtraRepositoryRows({
variant="ghost"
size="sm"
onClick={fs.addExtraRepository}
disabled={!primarySelected}
disabled={!canAddMore}
title={addButtonHint}
className="text-xs cursor-pointer text-muted-foreground hover:text-foreground"
data-testid="add-extra-repository"
>
@@ -71,6 +80,15 @@ export function ExtraRepositoryRows({
);
}
function computeAddButtonHint(
primarySelected: boolean,
remainingCount: number,
): string | undefined {
if (!primarySelected) return "Select the primary repository first";
if (remainingCount === 0) return "All workspace repositories are already added";
return undefined;
}
function computeBranchPlaceholder(
repoId: string,
branchesLoading: boolean,