fix(web): add repository creation to project settings

- Add repository creation dialog to the project settings repositories tab
- Reuse shared repository API for list/create/delete operations
- Add coverage for the create flow in the settings tab

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
This commit is contained in:
2026-05-22 18:34:02 +02:00
parent ae377baa74
commit cc1507a33e
2 changed files with 163 additions and 22 deletions
@@ -0,0 +1,59 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
import * as gitRepositoriesApi from "../api/git_repositories";
const mockRepositories = [
{
id: "repo-1",
name: "Main Repo",
path: "/repos/main",
project_id: "proj-1",
owner_id: "user-1",
is_mirror: false,
remote_url: null,
last_push: null,
created_at: null,
},
];
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
useParams: () => ({ projectId: "proj-1" }),
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("RepositoriesSettingsTab", () => {
it("opens create dialog and submits a repository", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: undefined,
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
});
@@ -1,37 +1,77 @@
import React, { useState, useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { GitRepository } from "../api/git_repositories";
import {
createRepository,
deleteRepository,
listRepositories,
type GitRepository,
type GitRepositoryCreate,
} from "../api/git_repositories";
import { Icon } from "./icon";
export const RepositoriesSettingsTab: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(">");
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formRemoteUrl, setFormRemoteUrl] = useState("");
const [error, setError] = useState("");
useEffect(() => {
const fetchRepositories = async () => {
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories`
);
setRepositories(response.data);
} catch (err) {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
};
const loadRepositories = useCallback(async () => {
if (!projectId) {
setLoading(false);
return;
}
fetchRepositories();
setLoading(true);
try {
const data = await listRepositories(projectId);
setRepositories(data);
} catch {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
setError("");
if (!projectId) return;
if (!formName.trim()) {
setError("Repository name is required");
return;
}
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: formRemoteUrl.trim() || undefined,
};
await createRepository(projectId, input);
setShowCreate(false);
setFormName("");
setFormRemoteUrl("");
await loadRepositories();
} catch {
setError("Failed to create repository");
}
};
const handleDelete = async (repoId: string) => {
if (!projectId) return;
if (!window.confirm("Are you sure you want to delete this repository?")) return;
try {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
setRepositories(repositories.filter((r) => r.id !== repoId));
} catch (err) {
await deleteRepository(projectId, repoId);
setRepositories((current) => current.filter((r) => r.id !== repoId));
} catch {
setError("Failed to delete repository");
}
};
@@ -40,7 +80,13 @@ export const RepositoriesSettingsTab: React.FC = () => {
return (
<div className="repositories-settings-tab">
<h2>Repositories</h2>
<div className="page-header">
<h2>Repositories</h2>
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
{error && <div className="error-message">{error}</div>}
<div className="repositories-list">
@@ -66,6 +112,42 @@ export const RepositoriesSettingsTab: React.FC = () => {
))
)}
</div>
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>Add Repository</h3>
<form onSubmit={handleCreate} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
<label className="form-field">
Remote URL (optional)
<input
type="text"
value={formRemoteUrl}
onChange={(event) => setFormRemoteUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
/>
</label>
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
Cancel
</button>
<button className="primary-button" type="submit">
Create
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};