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
+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>
);