feat(tickets): persistance de l'édition d'un ticket côté frontend

Ticket #9 — l'édition d'un ticket (titre, corps, champs) est désormais
persistée depuis le détail : useTicketDetail porte l'état d'édition et le
flux de sauvegarde, TicketDetail expose l'UI d'édition/validation.

Tests Vitest tickets verts (13/13), typecheck et build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 02:20:23 +02:00
parent ce6ef5efae
commit 5ecc64f9c5
3 changed files with 260 additions and 12 deletions

View File

@ -227,6 +227,126 @@ describe("TicketsView", () => {
);
});
it("preserves an in-progress description edit across a priority change (#9)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Persisted" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Persisted"));
const dialog = await screen.findByRole("dialog");
// Start editing the description WITHOUT saving.
const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement;
fireEvent.change(desc, { target: { value: "draft in progress" } });
// Also edit the title to cover both draft fields.
const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } });
// Change the priority — an immediate-apply mutation that re-fetches the ticket
// and bumps its version. The unsaved draft must survive.
fireEvent.change(within(dialog).getByLabelText("ticket priority"), {
target: { value: "high" },
});
// The backend applied the priority bump…
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.priority).toBe("high");
expect(fresh.version).toBe(2);
});
// …and the in-progress edits are still there (not reverted).
expect(
(within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value,
).toBe("draft in progress");
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement).value,
).toBe("Persisted (wip)");
// The description was never persisted.
const persisted = await ticket.read(PROJECT_ID, t.ref);
expect(persisted.description).toBe("");
});
it("prompts before closing with unsaved changes, and closes directly when clean (#9)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "CloseGuard" });
const onClose = vi.fn();
const gateways = { ticket, system, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={onClose}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
// Clean state → Close fires onClose directly, no confirmation dialog.
fireEvent.click(within(detail).getByText("Close"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull();
// Now make the draft dirty and try to close again.
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "unsaved body" },
});
fireEvent.click(within(detail).getByText("Close"));
// The confirmation appears and onClose was NOT called again.
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
expect(onClose).toHaveBeenCalledTimes(1);
// "Annuler" dismisses the popup and keeps the editor open.
fireEvent.click(within(confirm).getByLabelText("cancel close"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(),
);
expect(onClose).toHaveBeenCalledTimes(1);
// Re-open the confirmation and choose "Fermer sans sauvegarder".
fireEvent.click(within(detail).getByText("Close"));
const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm2).getByLabelText("close without saving"));
expect(onClose).toHaveBeenCalledTimes(2);
// The unsaved description was discarded (never persisted).
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.description).toBe("");
});
it("saves unsaved changes then closes when choosing « Sauvegarder » (#9)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "SaveOnClose" });
const onClose = vi.fn();
const gateways = { ticket, system, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={onClose}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "final body" },
});
fireEvent.click(within(detail).getByText("Close"));
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm).getByLabelText("save and close"));
// The change is persisted and the panel closes.
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.description).toBe("final body");
});
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
it("copies a delegation context prompt (F7)", async () => {
const writeText = stubClipboard();
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });