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:
@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user