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:
@@ -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 { 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 = () => {
|
export const RepositoriesSettingsTab: React.FC = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 loadRepositories = useCallback(async () => {
|
||||||
const fetchRepositories = async () => {
|
if (!projectId) {
|
||||||
try {
|
setLoading(false);
|
||||||
const response = await apiClient.get(
|
return;
|
||||||
`/projects/${projectId}/repositories`
|
}
|
||||||
);
|
|
||||||
setRepositories(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to load repositories");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRepositories();
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(projectId);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load repositories");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, [projectId]);
|
}, [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) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
|
if (!projectId) return;
|
||||||
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await deleteRepository(projectId, repoId);
|
||||||
setRepositories(repositories.filter((r) => r.id !== repoId));
|
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Failed to delete repository");
|
setError("Failed to delete repository");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -40,7 +80,13 @@ export const RepositoriesSettingsTab: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="repositories-settings-tab">
|
<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>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<div className="repositories-list">
|
<div className="repositories-list">
|
||||||
@@ -66,6 +112,42 @@ export const RepositoriesSettingsTab: React.FC = () => {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user