feat(ui): add delete confirmation modal
This commit is contained in:
parent
724b35d452
commit
d5f1856cc5
5 changed files with 119 additions and 10 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/components/ui/ConfirmModal.tsx
Normal file
74
src/components/ui/ConfirmModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue