refactor(web): reuse repository create dialog

- Extract the repository create dialog into a shared component
- Reuse the same clone/validation flow in project settings and repository management pages
- Keep the shared UI covered with focused tests

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
This commit is contained in:
2026-05-22 18:43:03 +02:00
parent ebfe991a15
commit 5af4de0d7e
4 changed files with 250 additions and 280 deletions
@@ -46,6 +46,7 @@ describe("RepositoriesSettingsTab", () => {
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(screen.getByLabelText(/clone existing repository/i));
fireEvent.change(screen.getByPlaceholderText(/github.com\/user\/repo.git/i), {
target: { value: "https://github.com/user/repo.git" },
});
@@ -72,6 +73,7 @@ describe("RepositoriesSettingsTab", () => {
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(screen.getByLabelText(/clone existing repository/i));
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
expect(screen.getByText(/remote url is required/i)).toBeInTheDocument();
@@ -1,13 +1,8 @@
import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {
createRepository,
deleteRepository,
listRepositories,
type GitRepository,
type GitRepositoryCreate,
} from "../api/git_repositories";
import { deleteRepository, listRepositories, type GitRepository } from "../api/git_repositories";
import { RepositoryCreateDialog } from "./repository-create-dialog";
import { Icon } from "./icon";
export const RepositoriesSettingsTab: React.FC = () => {
@@ -15,9 +10,6 @@ export const RepositoriesSettingsTab: React.FC = () => {
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [createMode, setCreateMode] = useState<"clone" | "blank">("clone");
const [formName, setFormName] = useState("");
const [formRemoteUrl, setFormRemoteUrl] = useState("");
const [error, setError] = useState("");
const loadRepositories = useCallback(async () => {
@@ -41,36 +33,6 @@ export const RepositoriesSettingsTab: React.FC = () => {
void loadRepositories();
}, [loadRepositories]);
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
setError("");
if (!projectId) return;
if (!formName.trim()) {
setError("Repository name is required");
return;
}
if (createMode === "clone" && !formRemoteUrl.trim()) {
setError("Remote URL is required to clone an existing repository");
return;
}
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: formRemoteUrl.trim() || undefined,
};
await createRepository(projectId, input);
setShowCreate(false);
setCreateMode("clone");
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;
@@ -124,62 +86,13 @@ export const RepositoriesSettingsTab: React.FC = () => {
</div>
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>Add Repository</h3>
<p className="muted">
Clone an existing repository from a git server, or create a blank bare repo here.
</p>
<form onSubmit={handleCreate} className="stack">
<div className="form-field">
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
Clone existing repository
</label>
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
Create blank repository
</label>
</div>
<label className="form-field">
Repository name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
<label className="form-field">
Remote URL {createMode === "clone" ? "(required)" : "(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">
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
<RepositoryCreateDialog
projectId={projectId!}
open={showCreate}
title="Add Repository"
onClose={() => setShowCreate(false)}
onCreated={loadRepositories}
/>
)}
</div>
);
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from "react";
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
import { Icon } from "./icon";
type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
interface RepositoryCreateDialogProps {
projectId: string;
open: boolean;
title: string;
onClose: () => void;
onCreated: () => Promise<void> | void;
}
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [formName, setFormName] = useState("");
const [formRemoteUrl, setFormRemoteUrl] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [open]);
useEffect(() => {
if (!open) return;
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (!formRemoteUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => {
try {
const result = await parseGitUrl(formRemoteUrl.trim());
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [formRemoteUrl, open]);
const resetForm = () => {
setCreateMode("clone");
setFormName("");
setFormRemoteUrl("");
setFormError(null);
setUrlValidation({ status: "idle", result: null });
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
if (createMode === "clone" && !formRemoteUrl.trim()) {
setFormError("Remote URL is required to clone an existing repository");
return;
}
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: formRemoteUrl.trim() || undefined,
};
await createRepository(projectId, input);
handleClose();
await onCreated();
} catch {
setFormError("Failed to create repository");
}
};
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setFormRemoteUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
if (!open) return null;
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>{title}</h3>
<p className="muted">
Clone an existing repository from a git server, or create a blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field">
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
Clone existing repository
</label>
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
Create blank repository
</label>
</div>
<label className="form-field">
Repository name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
<label className="form-field">
Remote URL {createMode === "clone" ? "(required)" : "(optional)"}
<input
type="text"
value={formRemoteUrl}
onChange={(event) => setFormRemoteUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
{formError && (
<div className="error-message">
<p className="error-text">{formError}</p>
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={handleClose} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
);
};
+9 -184
View File
@@ -1,19 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
createRepository,
deleteRepository,
listRepositories,
parseGitUrl,
type GitRepositoryCreate,
type URLParseResult,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
type RepoStatus = "loading" | "ready" | "error";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>();
@@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => {
const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formRemoteUrl, setFormRemoteUrl] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// URL validation state
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
setStatus("loading");
@@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => {
void loadRepositories();
}, [loadRepositories]);
// Validate URL with debounce
useEffect(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (!formRemoteUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => {
try {
const result = await parseGitUrl(formRemoteUrl.trim());
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [formRemoteUrl]);
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
if (!projectId) return;
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: formRemoteUrl.trim() || undefined,
};
await createRepository(projectId, input);
setShowCreate(false);
setFormName("");
setFormRemoteUrl("");
setUrlValidation({ status: "idle", result: null });
await loadRepositories();
} catch (err: unknown) {
const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } };
if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) {
// Show URL correction suggestion
const detail = axiosError.response.data.detail;
setFormError(
`${detail.message}\nSuggested: ${detail.suggested_url}`
);
} else {
setFormError("Failed to create repository");
}
}
};
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setFormRemoteUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
const handleDelete = async (repoId: string) => {
if (!projectId) return;
try {
@@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => {
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Create Repository</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="repository-name"
/>
</label>
<label className="form-field">
Remote URL (optional)
<input
type="text"
value={formRemoteUrl}
onChange={(e) => setFormRemoteUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">
Suggested: {urlValidation.result.base_url}
</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
{formError && (
<div className="error-message">
{formError.split("\n").map((line, i) => (
<p key={i} className="error-text">{line}</p>
))}
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
Create
</button>
</div>
</form>
</div>
</div>
<RepositoryCreateDialog
projectId={projectId!}
open={showCreate}
title="Create Repository"
onClose={() => setShowCreate(false)}
onCreated={loadRepositories}
/>
)}
</section>
);