feat(tickets): suppression d'un ticket (backend + frontend)
Ajoute la suppression complète d'un ticket (#6). Backend Rust : - port IssueStore::delete (NotFound si absent) + event IssueDeleted{freed_sprint} - FsIssueStore::delete : supprime .ideai/tickets/<N>/ et l'index sous lock - use case DeleteIssue + adaptations des tests sprint/ticket_assistant au port - commande Tauri ticket_delete + câblage events/state/lib Frontend : - gateway delete (ports, adapter ticket + mock, domain) - useTicketDetail : retrait de la liste et fermeture du détail via event issueDeleted - intégration TicketDetail + tests Tests verts : application/issue_usecases (6), infrastructure/issue_store (7), app-tauri --lib (56), frontend vitest (503), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -104,6 +104,14 @@ export function TicketDetail({
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Confirmation shown when the user tries to close with unsaved changes.
|
||||
const [confirmClose, setConfirmClose] = useState(false);
|
||||
// Confirmation shown before deleting the ticket (#6).
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// The ticket was deleted (by this view or another actor): close the surface.
|
||||
// The removal from lists flows from the same `issueDeleted` event (#6).
|
||||
useEffect(() => {
|
||||
if (vm.deleted) onClose();
|
||||
}, [vm.deleted, onClose]);
|
||||
|
||||
const reloadCount = vm.reloadCount;
|
||||
const seededReload = useRef(-1);
|
||||
@ -205,6 +213,16 @@ export function TicketDetail({
|
||||
>
|
||||
{copied ? "Copied" : "Delegation context"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!t || vm.busy}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="text-danger hover:text-danger"
|
||||
title="Supprimer définitivement ce ticket"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={requestClose}>
|
||||
Close
|
||||
</Button>
|
||||
@ -559,6 +577,52 @@ export function TicketDetail({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Delete confirmation (#6) ── */}
|
||||
{confirmDelete && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="confirmer la suppression"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Supprimer le ticket
|
||||
</h4>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Supprimer définitivement le ticket {ticketRef} ? Cette
|
||||
action est irréversible.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel delete"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
aria-label="confirm delete"
|
||||
loading={vm.busy}
|
||||
disabled={vm.busy}
|
||||
onClick={async () => {
|
||||
const ok = await vm.remove();
|
||||
// On success the surface closes via the `issueDeleted` event
|
||||
// (the `deleted` flag → onClose effect); just drop the dialog.
|
||||
if (ok) setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Agent } from "@/domain";
|
||||
import type { Agent, DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { TicketsView } from "./TicketsView";
|
||||
import { TicketDetail } from "./TicketDetail";
|
||||
@ -108,6 +108,34 @@ describe("MockTicketGateway", () => {
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||
});
|
||||
|
||||
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
|
||||
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
|
||||
const events: DomainEvent[] = [];
|
||||
await system.onDomainEvent((e) => events.push(e));
|
||||
|
||||
await ticket.delete(PROJECT_ID, t.ref);
|
||||
|
||||
// Removed from the store (NotFound afterwards)…
|
||||
await expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
// …and the sprint it was released from is reported on the event.
|
||||
expect(events).toContainEqual({
|
||||
type: "issueDeleted",
|
||||
projectId: PROJECT_ID,
|
||||
issueRef: t.ref,
|
||||
freedSprint: "s1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects deleting an unknown ticket with NotFound (#6)", async () => {
|
||||
await expect(ticket.delete(PROJECT_ID, "#404")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("links and unlinks tickets, bumping the version", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
||||
await ticket.create(PROJECT_ID, { title: "b" });
|
||||
@ -425,6 +453,69 @@ describe("TicketsView", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Deletable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Deletable"));
|
||||
const detail = await screen.findByRole("dialog", {
|
||||
name: `ticket ${t.ref}`,
|
||||
});
|
||||
|
||||
// Open the confirmation, then confirm the deletion.
|
||||
fireEvent.click(within(detail).getByText("Supprimer"));
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: "confirmer la suppression",
|
||||
});
|
||||
fireEvent.click(within(confirm).getByLabelText("confirm delete"));
|
||||
|
||||
// The gateway removed the ticket…
|
||||
await waitFor(() =>
|
||||
expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
}),
|
||||
);
|
||||
// …the detail closed (via the `issueDeleted` event → `deleted` flag)…
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
|
||||
).toBeNull(),
|
||||
);
|
||||
// …and the row disappeared from the list (event-driven refresh).
|
||||
await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
|
||||
expect(await screen.findByText("No tickets.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
|
||||
const delSpy = vi.spyOn(ticket, "delete");
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Keeper"));
|
||||
const detail = await screen.findByRole("dialog", {
|
||||
name: `ticket ${t.ref}`,
|
||||
});
|
||||
|
||||
fireEvent.click(within(detail).getByText("Supprimer"));
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: "confirmer la suppression",
|
||||
});
|
||||
fireEvent.click(within(confirm).getByLabelText("cancel delete"));
|
||||
|
||||
// The confirmation is dismissed, delete was never called, ticket stays.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "confirmer la suppression" }),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(delSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
|
||||
).toBeTruthy();
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.title).toBe("Keeper");
|
||||
});
|
||||
|
||||
it("copies a delegation context prompt (F7)", async () => {
|
||||
const writeText = stubClipboard();
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
|
||||
|
||||
@ -22,6 +22,12 @@ export interface TicketDetailViewModel {
|
||||
error: string | null;
|
||||
/** Set when the last write lost an optimistic-concurrency race (F3). */
|
||||
conflict: boolean;
|
||||
/**
|
||||
* Set once the open ticket has been deleted — by this view or any other actor
|
||||
* (ticket #6). Driven by the `issueDeleted` domain event (single source of
|
||||
* truth); the surface reacts by closing itself.
|
||||
*/
|
||||
deleted: boolean;
|
||||
busy: boolean;
|
||||
/**
|
||||
* Monotonic counter bumped **only** when the ticket is (re)loaded from the
|
||||
@ -42,6 +48,12 @@ export interface TicketDetailViewModel {
|
||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||
/**
|
||||
* Deletes this ticket (ticket #6). Returns `true` on success. The removal from
|
||||
* lists and the closing of this surface flow from the resulting `issueDeleted`
|
||||
* event (see {@link TicketDetailViewModel.deleted}), not from local mutation.
|
||||
*/
|
||||
remove: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
@ -69,6 +81,7 @@ export function useTicketDetail(
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [deleted, setDeleted] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reloadCount, setReloadCount] = useState(0);
|
||||
|
||||
@ -101,6 +114,12 @@ export function useTicketDetail(
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "issueDeleted" && event.issueRef === ref) {
|
||||
// The open ticket was deleted (here or elsewhere): don't refresh (it
|
||||
// would 404) — flag it so the surface closes (#6).
|
||||
setDeleted(true);
|
||||
return;
|
||||
}
|
||||
if (isTicketEvent(event) && event.issueRef === ref) void refresh();
|
||||
})
|
||||
.then((u) => {
|
||||
@ -186,10 +205,27 @@ export function useTicketDetail(
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const remove: TicketDetailViewModel["remove"] = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gateway.delete(projectId, ref);
|
||||
// Closing/removal flows from the `issueDeleted` event (single source of
|
||||
// truth) — see the `deleted` flag set in the subscription above.
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [gateway, projectId, ref]);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
error,
|
||||
conflict,
|
||||
deleted,
|
||||
busy,
|
||||
reloadCount,
|
||||
refresh,
|
||||
@ -198,5 +234,6 @@ export function useTicketDetail(
|
||||
link,
|
||||
unlink,
|
||||
assign,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user