feat(tickets): assignation de sprint en masse via multi-sélection

Permet de sélectionner plusieurs tickets dans TicketsPanel et de leur
assigner un sprint en une seule action, au lieu d'un ticket à la fois.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:58:00 +02:00
parent 6bd5e64dd1
commit f5007fcb11
3 changed files with 176 additions and 0 deletions

View File

@ -886,6 +886,76 @@ describe("TicketsView", () => {
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
});
it("selects several rows and assigns their sprint from the bulk action bar (#112)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const a = await ticket.create(PROJECT_ID, { title: "Sprint Bulk A" });
const b = await ticket.create(PROJECT_ID, { title: "Sprint Bulk B" });
await ticket.create(PROJECT_ID, { title: "Unselected sprint item" });
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
await waitFor(() => {
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, a.ref, "s1", a.version);
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, b.ref, "s1", b.version);
});
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBe("s1");
});
expect(screen.getByRole("status").textContent).toMatch(
/Sprint mis à jour pour 2 tickets/i,
);
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
});
it("keeps failed refs selected after a partial bulk sprint assignment (#112)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const a = await ticket.create(PROJECT_ID, { title: "Sprint Partial A" });
const b = await ticket.create(PROJECT_ID, { title: "Sprint Partial B" });
const originalSetTicketSprint = ticket.setTicketSprint.bind(ticket);
vi.spyOn(ticket, "setTicketSprint").mockImplementation(
async (projectId, ref, sprintId, expectedVersion) => {
if (ref === b.ref) {
throw { code: "CONFLICT", message: "version conflict" };
}
return originalSetTicketSprint(projectId, ref, sprintId, expectedVersion);
},
);
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBeNull();
});
expect((await screen.findByRole("alert")).textContent).toContain(
`1 ticket(s) non modifié(s): ${b.ref}`,
);
expect(screen.getByRole("status").textContent).toMatch(/1\/2 tickets/i);
expect(
(screen.getByLabelText(`select ticket row ${b.ref}`) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText(`select ticket row ${a.ref}`) as HTMLInputElement)
.checked,
).toBe(false);
});
it("selects several rows and deletes them after bulk confirmation", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });