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:
2026-07-06 06:27:09 +02:00
parent d25e340b44
commit 5417bd756b
20 changed files with 689 additions and 95 deletions

View File

@ -2012,6 +2012,22 @@ export class MockTicketGateway implements TicketGateway {
return structuredClone(ticket);
}
async delete(projectId: string, ref: string): Promise<void> {
// NotFound if absent (mirrors the backend `AppError::NotFound`).
const ticket = this.require(projectId, ref);
const freedSprint = ticket.sprintId ?? null;
this.projectTickets(projectId).delete(ref);
this.projectCarnets(projectId).delete(ref);
// Releasing the ticket updates its former sprint's count.
if (freedSprint) this.recountSprints(projectId);
this.system?.emit({
type: "issueDeleted",
projectId,
issueRef: ref,
freedSprint,
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
const ticket = this.require(projectId, ref);
return {

View File

@ -73,6 +73,15 @@ export class TauriTicketGateway implements TicketGateway {
});
}
async delete(projectId: string, ref: string): Promise<void> {
// `ticket_delete` takes the shared `{ request }` envelope like every other
// ticket command; a missing ref surfaces as a `NOT_FOUND` GatewayError from
// the backend, propagated as-is.
await invoke<void>("ticket_delete", {
request: { projectId, ref },
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return invoke<TicketCarnet>("ticket_read_carnet", {
request: { projectId, ref },

View File

@ -126,6 +126,19 @@ export type DomainEvent =
}
| { type: "issueCreated"; issueId: string; issueRef: string }
| { type: "issueUpdated"; issueRef: string; version: number }
| {
/**
* A ticket was deleted (ticket #6). Mirror of the backend
* `DomainEventDto::IssueDeleted`. `issueRef` is the deleted `#N`;
* `freedSprint` is the sprint id it was released from, when any. Starts with
* `issue`, so {@link isTicketEvent} matches it and ticket lists refresh
* automatically; an open detail on the same `issueRef` closes.
*/
type: "issueDeleted";
projectId: string;
issueRef: string;
freedSprint: string | null;
}
| {
type: "issueStatusChanged";
issueRef: string;

View File

@ -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&nbsp;{ticketRef}&nbsp;? 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>
);
}

View File

@ -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" });

View File

@ -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,
};
}

View File

@ -764,6 +764,14 @@ export interface TicketGateway {
ref: string,
input: UpdateTicketInput,
): Promise<Ticket>;
/**
* Deletes a ticket (ticket #6). Rejects with a `notFound` {@link GatewayError}
* when the ref does not exist. On success the backend emits an `issueDeleted`
* domain event (payload `{ projectId, issueRef, freedSprint }`) — the single
* source of truth that drives the ticket's removal from lists and the closing
* of an open detail view. Callers must not mutate local state imperatively.
*/
delete(projectId: string, ref: string): Promise<void>;
/** Reads the ticket-scoped Markdown carnet. */
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
/** Replaces (not appends) the ticket-scoped carnet body. */