feat(ui): add delete confirmation modal

This commit is contained in:
thibaud-leclere 2026-04-14 15:27:29 +02:00
parent 724b35d452
commit d5f1856cc5
5 changed files with 119 additions and 10 deletions

View file

@ -4,6 +4,7 @@ import { useParams, Link, useNavigate } from "react-router-dom";
import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api"; import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api";
import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types"; import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types";
import TrackerList from "../trackers/TrackerList"; import TrackerList from "../trackers/TrackerList";
import ConfirmModal from "../ui/ConfirmModal";
type ActivityLevel = "info" | "success" | "error"; type ActivityLevel = "info" | "success" | "error";
@ -41,6 +42,7 @@ export default function ProjectDashboard() {
const [activity, setActivity] = useState<ActivityItem[]>([]); const [activity, setActivity] = useState<ActivityItem[]>([]);
const [activePolls, setActivePolls] = useState<Record<string, string>>({}); const [activePolls, setActivePolls] = useState<Record<string, string>>({});
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({}); const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
function appendActivity(level: ActivityLevel, message: string) { function appendActivity(level: ActivityLevel, message: string) {
const item: ActivityItem = { const item: ActivityItem = {
@ -218,8 +220,7 @@ export default function ProjectDashboard() {
async function handleDelete() { async function handleDelete() {
if (!projectId) return; if (!projectId) return;
if (!window.confirm(`Delete project "${project?.name}"?`)) return; setIsDeleteModalOpen(false);
await deleteProject(projectId); await deleteProject(projectId);
window.dispatchEvent(new Event("orchai:refresh-projects")); window.dispatchEvent(new Event("orchai:refresh-projects"));
navigate("/"); navigate("/");
@ -258,7 +259,7 @@ export default function ProjectDashboard() {
Edit Edit
</Link> </Link>
<button <button
onClick={handleDelete} onClick={() => setIsDeleteModalOpen(true)}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200" className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
> >
Delete Delete
@ -292,6 +293,12 @@ export default function ProjectDashboard() {
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} /> <TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />
</div> </div>
<ConfirmModal
isOpen={isDeleteModalOpen}
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={() => void handleDelete()}
/>
<div className="mt-8"> <div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Live Activity</h3> <h3 className="text-lg font-semibold mb-4">Live Activity</h3>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4"> <div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">

View file

@ -7,6 +7,7 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { TuleapCredentialsSafe } from "../../lib/types"; import type { TuleapCredentialsSafe } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
export default function SettingsPage() { export default function SettingsPage() {
const [tuleapUrl, setTuleapUrl] = useState(""); const [tuleapUrl, setTuleapUrl] = useState("");
@ -18,6 +19,7 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
getTuleapCredentials().then((creds) => { getTuleapCredentials().then((creds) => {
@ -64,7 +66,7 @@ export default function SettingsPage() {
} }
async function handleDelete() { async function handleDelete() {
if (!window.confirm("Delete Tuleap credentials? This cannot be undone.")) return; setIsDeleteModalOpen(false);
clearMessages(); clearMessages();
setDeleting(true); setDeleting(true);
try { try {
@ -160,7 +162,7 @@ export default function SettingsPage() {
{existing && ( {existing && (
<button <button
type="button" type="button"
onClick={handleDelete} onClick={() => setIsDeleteModalOpen(true)}
disabled={deleting} disabled={deleting}
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50" className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50"
> >
@ -170,6 +172,13 @@ export default function SettingsPage() {
</div> </div>
</form> </form>
</div> </div>
<ConfirmModal
isOpen={isDeleteModalOpen}
confirmDisabled={deleting}
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={() => void handleDelete()}
/>
</div> </div>
); );
} }

View file

@ -12,6 +12,7 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { ProcessedTicket, Worktree } from "../../lib/types"; import type { ProcessedTicket, Worktree } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
function statusBadgeClass(status: string): string { function statusBadgeClass(status: string): string {
switch (status) { switch (status) {
@ -67,6 +68,7 @@ export default function TicketDetail() {
const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info"); const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false);
async function loadData() { async function loadData() {
if (!ticketId) return; if (!ticketId) return;
@ -136,7 +138,7 @@ export default function TicketDetail() {
async function handleDeleteWorktree() { async function handleDeleteWorktree() {
if (!worktree) return; if (!worktree) return;
if (!window.confirm("Delete this worktree and its branch?")) return; setIsDeleteWorktreeModalOpen(false);
setLoading(true); setLoading(true);
try { try {
await deleteWorktreeCmd(worktree.id); await deleteWorktreeCmd(worktree.id);
@ -289,7 +291,7 @@ export default function TicketDetail() {
</button> </button>
</div> </div>
<button <button
onClick={handleDeleteWorktree} onClick={() => setIsDeleteWorktreeModalOpen(true)}
disabled={loading} disabled={loading}
className="text-sm text-red-600 hover:underline" className="text-sm text-red-600 hover:underline"
> >
@ -319,6 +321,13 @@ export default function TicketDetail() {
)} )}
{tab === "diff" && <DiffViewer diff={diff || ""} />} {tab === "diff" && <DiffViewer diff={diff || ""} />}
<ConfirmModal
isOpen={isDeleteWorktreeModalOpen}
confirmDisabled={loading}
onCancel={() => setIsDeleteWorktreeModalOpen(false)}
onConfirm={() => void handleDeleteWorktree()}
/>
</div> </div>
); );
} }

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { manualPoll, updateTracker, removeTracker } from "../../lib/api"; import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
import type { WatchedTracker } from "../../lib/types"; import type { WatchedTracker } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
interface Props { interface Props {
trackers: WatchedTracker[]; trackers: WatchedTracker[];
@ -11,6 +12,7 @@ interface Props {
export default function TrackerList({ trackers, projectId, onRefresh }: Props) { export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
const [pollingIds, setPollingIds] = useState<string[]>([]); const [pollingIds, setPollingIds] = useState<string[]>([]);
const [trackerToRemove, setTrackerToRemove] = useState<WatchedTracker | null>(null);
async function handlePollNow(tracker: WatchedTracker) { async function handlePollNow(tracker: WatchedTracker) {
try { try {
@ -41,8 +43,10 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
} }
} }
async function handleRemove(tracker: WatchedTracker) { async function handleConfirmRemove() {
if (!window.confirm(`Remove tracker "${tracker.tracker_label}"?`)) return; if (!trackerToRemove) return;
const tracker = trackerToRemove;
setTrackerToRemove(null);
try { try {
await removeTracker(tracker.id); await removeTracker(tracker.id);
onRefresh(); onRefresh();
@ -107,7 +111,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleRemove(tracker)} onClick={() => setTrackerToRemove(tracker)}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200" className="px-3 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200"
> >
Remove Remove
@ -122,6 +126,12 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
> >
Add tracker Add tracker
</Link> </Link>
<ConfirmModal
isOpen={trackerToRemove !== null}
onCancel={() => setTrackerToRemove(null)}
onConfirm={() => void handleConfirmRemove()}
/>
</div> </div>
); );
} }

View file

@ -0,0 +1,74 @@
import { useEffect } from "react";
interface ConfirmModalProps {
isOpen: boolean;
message?: string;
title?: string;
confirmLabel?: string;
cancelLabel?: string;
confirmDisabled?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmModal({
isOpen,
message = "Êtes-vous sûr de vouloir supprimer cet item ?",
title = "Confirmer la suppression",
confirmLabel = "Supprimer",
cancelLabel = "Annuler",
confirmDisabled = false,
onConfirm,
onCancel,
}: ConfirmModalProps) {
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onCancel]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded bg-gray-200 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-300"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={confirmDisabled}
className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}