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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user