From 0039958b821d66311b9da3d91d7bb272516fb069 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 12 Jul 2026 20:03:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20attribution=20d'un=20sprint=20?= =?UTF-8?q?=C3=A0=20la=20cr=C3=A9ation=20via=20popup=20SprintPicker=20(#38?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/tickets/SprintPicker.test.tsx | 164 ++++++++++ .../src/features/tickets/SprintPicker.tsx | 280 ++++++++++++++++++ .../src/features/tickets/TicketsPanel.tsx | 48 ++- frontend/src/features/tickets/index.ts | 2 + .../src/features/tickets/tickets.test.tsx | 74 +++++ 5 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/tickets/SprintPicker.test.tsx create mode 100644 frontend/src/features/tickets/SprintPicker.tsx diff --git a/frontend/src/features/tickets/SprintPicker.test.tsx b/frontend/src/features/tickets/SprintPicker.test.tsx new file mode 100644 index 0000000..a3a1158 --- /dev/null +++ b/frontend/src/features/tickets/SprintPicker.test.tsx @@ -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> = {}, +) { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const gateways = { ticket } as unknown as Gateways; + const utils = render( + + + , + ); + 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( + + + , + ); + 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"); + }); +}); diff --git a/frontend/src/features/tickets/SprintPicker.tsx b/frontend/src/features/tickets/SprintPicker.tsx new file mode 100644 index 0000000..23a533f --- /dev/null +++ b/frontend/src/features/tickets/SprintPicker.tsx @@ -0,0 +1,280 @@ +/** + * Sprint-selection popup (ticket #38) — the sprint analogue of + * {@link TicketPicker}. A modal that lists the project's sprints (via + * {@link TicketGateway.listSprints}) and lets the caller pick one, or the + * explicit **« Sans sprint »** entry to clear the assignment. + * + * Used by the ticket-creation form to attach a sprint at creation time, but it + * is a generic read-only picker: it never mutates: it just hands the chosen + * `Sprint` (or `null` for "no sprint") back through `onSelect`. + * + * Integration rules (mirrors {@link TicketPicker}, note + * `ui-rework-sprint-scoping-contracts`): + * - G5: mount at the **chrome/panel level**, never inside LayoutGrid/LeafView. A + * focus-trap is mandatory because xterm captures keyboard focus. + * - z-index `floatingWindowNested` (60): it opens from within other floating + * surfaces (the create form), so it must sit above them. + */ + +import { useEffect, useRef, useState } from "react"; + +import type { GatewayError, Sprint } from "@/domain"; +import { Button, Spinner, cn, zIndex } from "@/shared"; +import { useGateways } from "@/app/di"; + +export interface SprintPickerProps { + /** Whether the popup is shown. Rendered as `null` when closed. */ + open: boolean; + projectId: string; + /** The currently-selected sprint id (highlighted); `null`/absent ⇒ none. */ + selectedSprintId?: string | null; + /** Dialog heading (default "Sélectionner un sprint"). */ + title?: string; + /** Selection callback — a `Sprint`, or `null` for the "Sans sprint" entry. */ + onSelect: (sprint: Sprint | null) => void; + /** Called on Escape, backdrop click, or the Close button. */ + onClose: () => void; +} + +/** Selector for tabbable elements, used by the focus-trap. */ +const FOCUSABLE = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +export function SprintPicker({ + open, + projectId, + selectedSprintId, + title = "Sélectionner un sprint", + onSelect, + onClose, +}: SprintPickerProps) { + if (!open) return null; + return ( + + ); +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +/** + * The mounted picker. Split from {@link SprintPicker} so the sprint fetch only + * runs while the popup is actually open — mounting/unmounting on `open` resets + * state cleanly between openings. + */ +function SprintPickerBody({ + projectId, + selectedSprintId, + title, + onSelect, + onClose, +}: Required>) { + const { ticket } = useGateways(); + const dialogRef = useRef(null); + const [sprints, setSprints] = useState(null); + const [error, setError] = useState(null); + + // Load the project's sprints once on mount. + useEffect(() => { + let cancelled = false; + setError(null); + void ticket + .listSprints(projectId) + .then((list) => { + if (!cancelled) setSprints(list); + }) + .catch((e) => { + if (!cancelled) { + setError(describe(e)); + setSprints([]); + } + }); + return () => { + cancelled = true; + }; + }, [ticket, projectId]); + + // Focus-trap (G5): mirror TicketPicker — move focus in on mount, keep Tab + // cycling inside the dialog, restore focus on close, and close on Escape. + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + const node = dialogRef.current; + const first = node?.querySelector(FOCUSABLE); + first?.focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== "Tab" || !node) return; + const focusables = Array.from( + node.querySelectorAll(FOCUSABLE), + ).filter((el) => el.offsetParent !== null || el === document.activeElement); + if (focusables.length === 0) return; + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === firstEl) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocused?.focus?.(); + }; + }, [onClose]); + + const ordered = sprints + ? [...sprints].sort((a, b) => a.order - b.order) + : []; + + function pick(sprint: Sprint | null) { + onSelect(sprint); + onClose(); + } + + return ( +
{ + // Backdrop click closes; clicks inside the panel don't bubble here. + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* ── Header ── */} +
+ {title} + +
+ + {error && ( +

+ {error} +

+ )} + + {/* ── Options ── */} +
+
    + {/* "Sans sprint" always available (clears the assignment). */} +
  • + pick(null)} + /> +
  • + + {sprints === null ? ( +
  • + + Loading sprints… +
  • + ) : ( + ordered.map((s) => ( +
  • + pick(s)} + /> +
  • + )) + )} +
+ + {sprints !== null && ordered.length === 0 && !error && ( +

No sprints yet.

+ )} +
+
+
+ ); +} + +/** One selectable row (checkmark on the active entry), shared shape/styling. */ +function SprintOption({ + label, + ariaLabel, + count, + selected, + onClick, +}: { + label: string; + ariaLabel: string; + count?: number; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index abff0a8..b4a5a03 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -14,6 +14,7 @@ import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; import { SprintManager } from "./SprintManager"; +import { SprintPicker } from "./SprintPicker"; import { TicketFacetsBar } from "./TicketFacetsBar"; import { PriorityBadge, @@ -42,6 +43,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { const [showSprints, setShowSprints] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newPriority, setNewPriority] = useState("medium"); + // Sprint chosen for the new ticket (`null` ⇒ "Sans sprint"), and whether its + // picker popup is open (#38). + const [newSprint, setNewSprint] = useState(null); + const [showSprintPicker, setShowSprintPicker] = useState(false); const items = vm.list?.items ?? []; @@ -66,12 +71,18 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { title: newTitle.trim(), priority: newPriority, }); - if (created) { - setNewTitle(""); - setNewPriority("medium"); - setShowCreate(false); - onOpen(created.ref); + if (!created) return; // create failed; `vm.error` already carries the reason. + // Two-step assignment (#38): the ticket exists either way. If the sprint + // assignment fails, the ticket is kept and `vm.assignSprint` surfaces the + // failure via `vm.error` — we never lose the created ticket. + if (newSprint) { + await vm.assignSprint(created.ref, newSprint.id); } + setNewTitle(""); + setNewPriority("medium"); + setNewSprint(null); + setShowCreate(false); + onOpen(created.ref); } return ( @@ -146,6 +157,22 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { Create + {/* Sprint field (#38): opens the SprintPicker popup; shows the choice + (or "Sans sprint"). */} +
+ Sprint + +
)} @@ -252,6 +279,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { onClose={() => setShowSprints(false)} /> )} + + {/* Sprint picker for the create form (#38) — chrome-level, nested z-index, + focus-trapped. Selecting an entry sets the pending sprint and closes. */} + setNewSprint(sprint)} + onClose={() => setShowSprintPicker(false)} + /> ); } diff --git a/frontend/src/features/tickets/index.ts b/frontend/src/features/tickets/index.ts index c9364af..f63540c 100644 --- a/frontend/src/features/tickets/index.ts +++ b/frontend/src/features/tickets/index.ts @@ -15,6 +15,8 @@ export type { TicketPickerResult, TicketPickerSelectionMode, } from "./TicketPicker"; +export { SprintPicker } from "./SprintPicker"; +export type { SprintPickerProps } from "./SprintPicker"; export { TicketFacetsBar } from "./TicketFacetsBar"; export type { TicketFacetsBarProps } from "./TicketFacetsBar"; export { useTicketSearch } from "./useTicketSearch"; diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index f2f7041..cd4190f 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -214,6 +214,80 @@ describe("TicketsView", () => { expect(await screen.findByText("Live created")).toBeTruthy(); }); + it("creates a ticket and assigns the sprint chosen via the picker (#38)", async () => { + ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" }); + const setSpy = vi.spyOn(ticket, "setTicketSprint"); + + renderView(ticket, system, agent); + fireEvent.click(await screen.findByRole("button", { name: "New" })); + fireEvent.change(screen.getByLabelText("new ticket title"), { + target: { value: "With sprint" }, + }); + + // Field defaults to "Sans sprint"; open the picker and pick Alpha. + const sprintField = screen.getByLabelText("choose sprint for new ticket"); + expect(sprintField.textContent).toContain("Sans sprint"); + fireEvent.click(sprintField); + fireEvent.click(await screen.findByLabelText("select sprint Alpha")); + expect( + screen.getByLabelText("choose sprint for new ticket").textContent, + ).toContain("Alpha"); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + // create → setTicketSprint(projectId, ref, "s1", version). + await waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1)); + const call = setSpy.mock.calls[0]; + expect(call[0]).toBe(PROJECT_ID); + expect(call[2]).toBe("s1"); + await waitFor(async () => { + const fresh = await ticket.read(PROJECT_ID, call[1]); + expect(fresh.sprintId).toBe("s1"); + }); + }); + + it("creates a ticket without a sprint — setTicketSprint is not called (#38)", async () => { + const setSpy = vi.spyOn(ticket, "setTicketSprint"); + + renderView(ticket, system, agent); + fireEvent.click(await screen.findByRole("button", { name: "New" })); + fireEvent.change(screen.getByLabelText("new ticket title"), { + target: { value: "No sprint" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(async () => { + const list = await ticket.list(PROJECT_ID); + expect(list.items.map((i) => i.title)).toContain("No sprint"); + }); + expect(setSpy).not.toHaveBeenCalled(); + }); + + it("keeps the created ticket and surfaces the error when sprint assignment fails (#38)", async () => { + ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" }); + vi.spyOn(ticket, "setTicketSprint").mockRejectedValueOnce({ + code: "INTERNAL", + message: "assign boom", + }); + + renderView(ticket, system, agent); + fireEvent.click(await screen.findByRole("button", { name: "New" })); + fireEvent.change(screen.getByLabelText("new ticket title"), { + target: { value: "Kept anyway" }, + }); + fireEvent.click(screen.getByLabelText("choose sprint for new ticket")); + fireEvent.click(await screen.findByLabelText("select sprint Alpha")); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + // The ticket was created despite the failed assignment (no data lost)… + await waitFor(async () => { + const list = await ticket.list(PROJECT_ID); + expect(list.items.map((i) => i.title)).toContain("Kept anyway"); + }); + // …and the assignment failure is surfaced. + expect(await screen.findByText(/assign boom/)).toBeTruthy(); + }); + it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => { const listSpy = vi.spyOn(ticket, "list"); await ticket.create(PROJECT_ID, {