feat(tickets): attribution d'un sprint à la création via popup SprintPicker (#38)

Nouveau composant SprintPicker permettant de choisir un sprint lors de
la création d'un ticket depuis TicketsPanel ; export ajouté à l'index
des features tickets. Frontend-pur.

QA vert : tsc --noEmit exit 0, vitest 63 fichiers / 634 tests passés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:03:41 +02:00
parent a3c0dd410a
commit 0039958b82
5 changed files with 563 additions and 5 deletions

View File

@ -0,0 +1,164 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { SprintPicker } from "./SprintPicker";
const PROJECT_ID = "project-sprint-picker";
function renderPicker(
ticket: MockTicketGateway,
props: Partial<React.ComponentProps<typeof SprintPicker>> = {},
) {
const onSelect = vi.fn();
const onClose = vi.fn();
const gateways = { ticket } as unknown as Gateways;
const utils = render(
<DIProvider gateways={gateways}>
<SprintPicker
open
projectId={PROJECT_ID}
onSelect={onSelect}
onClose={onClose}
{...props}
/>
</DIProvider>,
);
return { ...utils, onSelect, onClose };
}
/** Seeds two ordered sprints. */
function seedSprints(ticket: MockTicketGateway) {
const beta = ticket._seedSprint(PROJECT_ID, {
id: "s2",
order: 2,
name: "Beta",
ticketCount: 3,
});
const alpha = ticket._seedSprint(PROJECT_ID, {
id: "s1",
order: 1,
name: "Alpha",
});
return { alpha, beta };
}
describe("SprintPicker (#38)", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
});
it("renders nothing when closed", () => {
const gateways = { ticket } as unknown as Gateways;
const { container } = render(
<DIProvider gateways={gateways}>
<SprintPicker
open={false}
projectId={PROJECT_ID}
onSelect={vi.fn()}
onClose={vi.fn()}
/>
</DIProvider>,
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it("lists the project's sprints (ordered) plus a « Sans sprint » entry", async () => {
seedSprints(ticket);
renderPicker(ticket);
// "Sans sprint" is always present.
expect(screen.getByLabelText("select no sprint")).toBeTruthy();
// Both sprints appear once loaded, ordered by `order` (Alpha before Beta).
await screen.findByLabelText("select sprint Alpha");
const options = screen
.getAllByRole("option")
.map((n) => n.getAttribute("aria-label"));
expect(options).toEqual([
"select no sprint",
"select sprint Alpha",
"select sprint Beta",
]);
});
it("selecting a sprint hands back the sprint and closes", async () => {
const { alpha } = seedSprints(ticket);
const { onSelect, onClose } = renderPicker(ticket);
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0]).toMatchObject({
id: alpha.id,
name: "Alpha",
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("selecting « Sans sprint » hands back null and closes", async () => {
seedSprints(ticket);
const { onSelect, onClose } = renderPicker(ticket, {
selectedSprintId: "s1",
});
await screen.findByLabelText("select sprint Alpha");
fireEvent.click(screen.getByLabelText("select no sprint"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0]).toBeNull();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("marks the currently-selected sprint as aria-selected", async () => {
seedSprints(ticket);
renderPicker(ticket, { selectedSprintId: "s2" });
const beta = await screen.findByLabelText("select sprint Beta");
expect(beta.getAttribute("aria-selected")).toBe("true");
expect(
screen.getByLabelText("select sprint Alpha").getAttribute("aria-selected"),
).toBe("false");
// "Sans sprint" is not selected when a sprint id is active.
expect(
screen.getByLabelText("select no sprint").getAttribute("aria-selected"),
).toBe("false");
});
it("shows an empty state when the project has no sprints", async () => {
renderPicker(ticket);
expect(await screen.findByText("No sprints yet.")).toBeTruthy();
// "Sans sprint" is still offered so the user can clear an assignment.
expect(screen.getByLabelText("select no sprint")).toBeTruthy();
});
it("closes on Escape and on backdrop click", async () => {
seedSprints(ticket);
const { onClose } = renderPicker(ticket);
await screen.findByLabelText("select sprint Alpha");
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
const dialog = screen.getByRole("dialog");
const backdrop = dialog.parentElement as HTMLElement;
fireEvent.mouseDown(backdrop);
expect(onClose).toHaveBeenCalledTimes(2);
});
it("surfaces a listSprints failure", async () => {
vi.spyOn(ticket, "listSprints").mockRejectedValueOnce({
code: "INTERNAL",
message: "boom",
});
renderPicker(ticket);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("boom");
});
});