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");
});
});

View File

@ -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 (
<SprintPickerBody
projectId={projectId}
selectedSprintId={selectedSprintId ?? null}
title={title}
onSelect={onSelect}
onClose={onClose}
/>
);
}
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<Omit<SprintPickerProps, "open">>) {
const { ticket } = useGateways();
const dialogRef = useRef<HTMLDivElement>(null);
const [sprints, setSprints] = useState<Sprint[] | null>(null);
const [error, setError] = useState<string | null>(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<HTMLElement>(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<HTMLElement>(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 (
<div
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
style={{ zIndex: zIndex.floatingWindowNested }}
onMouseDown={(e) => {
// Backdrop click closes; clicks inside the panel don't bubble here.
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
className="flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-xl"
>
{/* ── Header ── */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<span className="text-sm font-medium text-content">{title}</span>
<Button
size="sm"
variant="ghost"
aria-label="close sprint picker"
onClick={onClose}
>
Close
</Button>
</header>
{error && (
<p
role="alert"
className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger"
>
{error}
</p>
)}
{/* ── Options ── */}
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
<ul className="flex flex-col divide-y divide-border">
{/* "Sans sprint" always available (clears the assignment). */}
<li className="py-1.5 first:pt-0">
<SprintOption
label="Sans sprint"
ariaLabel="select no sprint"
selected={selectedSprintId === null}
onClick={() => pick(null)}
/>
</li>
{sprints === null ? (
<li className="flex items-center gap-2 py-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading sprints</span>
</li>
) : (
ordered.map((s) => (
<li key={s.id} className="py-1.5 last:pb-0">
<SprintOption
label={s.name}
ariaLabel={`select sprint ${s.name}`}
count={s.ticketCount}
selected={selectedSprintId === s.id}
onClick={() => pick(s)}
/>
</li>
))
)}
</ul>
{sprints !== null && ordered.length === 0 && !error && (
<p className="mt-2 text-sm text-muted">No sprints yet.</p>
)}
</div>
</div>
</div>
);
}
/** 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 (
<button
type="button"
role="option"
aria-selected={selected}
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none",
selected && "bg-raised",
)}
>
<span
aria-hidden="true"
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center text-[10px] font-bold",
selected ? "text-primary" : "text-transparent",
)}
>
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{label}
</span>
{count !== undefined && (
<span className="shrink-0 rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
{count}
</span>
)}
</button>
);
}

View File

@ -14,6 +14,7 @@ import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useTickets } from "./useTickets"; import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents"; import { useProjectAgents } from "./useProjectAgents";
import { SprintManager } from "./SprintManager"; import { SprintManager } from "./SprintManager";
import { SprintPicker } from "./SprintPicker";
import { TicketFacetsBar } from "./TicketFacetsBar"; import { TicketFacetsBar } from "./TicketFacetsBar";
import { import {
PriorityBadge, PriorityBadge,
@ -42,6 +43,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const [showSprints, setShowSprints] = useState(false); const [showSprints, setShowSprints] = useState(false);
const [newTitle, setNewTitle] = useState(""); const [newTitle, setNewTitle] = useState("");
const [newPriority, setNewPriority] = useState<TicketPriority>("medium"); const [newPriority, setNewPriority] = useState<TicketPriority>("medium");
// Sprint chosen for the new ticket (`null` ⇒ "Sans sprint"), and whether its
// picker popup is open (#38).
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
const [showSprintPicker, setShowSprintPicker] = useState(false);
const items = vm.list?.items ?? []; const items = vm.list?.items ?? [];
@ -66,12 +71,18 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
title: newTitle.trim(), title: newTitle.trim(),
priority: newPriority, priority: newPriority,
}); });
if (created) { if (!created) return; // create failed; `vm.error` already carries the reason.
setNewTitle(""); // Two-step assignment (#38): the ticket exists either way. If the sprint
setNewPriority("medium"); // assignment fails, the ticket is kept and `vm.assignSprint` surfaces the
setShowCreate(false); // failure via `vm.error` — we never lose the created ticket.
onOpen(created.ref); if (newSprint) {
await vm.assignSprint(created.ref, newSprint.id);
} }
setNewTitle("");
setNewPriority("medium");
setNewSprint(null);
setShowCreate(false);
onOpen(created.ref);
} }
return ( return (
@ -146,6 +157,22 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
Create Create
</Button> </Button>
</div> </div>
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice
(or "Sans sprint"). */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Sprint</span>
<Button
type="button"
size="sm"
variant="ghost"
aria-label="choose sprint for new ticket"
disabled={vm.busy}
onClick={() => setShowSprintPicker(true)}
className="border border-border"
>
{newSprint ? newSprint.name : "Sans sprint"}
</Button>
</div>
</form> </form>
)} )}
@ -252,6 +279,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onClose={() => setShowSprints(false)} 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. */}
<SprintPicker
open={showSprintPicker}
projectId={projectId}
selectedSprintId={newSprint?.id ?? null}
title="Sprint du nouveau ticket"
onSelect={(sprint) => setNewSprint(sprint)}
onClose={() => setShowSprintPicker(false)}
/>
</Panel> </Panel>
); );
} }

View File

@ -15,6 +15,8 @@ export type {
TicketPickerResult, TicketPickerResult,
TicketPickerSelectionMode, TicketPickerSelectionMode,
} from "./TicketPicker"; } from "./TicketPicker";
export { SprintPicker } from "./SprintPicker";
export type { SprintPickerProps } from "./SprintPicker";
export { TicketFacetsBar } from "./TicketFacetsBar"; export { TicketFacetsBar } from "./TicketFacetsBar";
export type { TicketFacetsBarProps } from "./TicketFacetsBar"; export type { TicketFacetsBarProps } from "./TicketFacetsBar";
export { useTicketSearch } from "./useTicketSearch"; export { useTicketSearch } from "./useTicketSearch";

View File

@ -214,6 +214,80 @@ describe("TicketsView", () => {
expect(await screen.findByText("Live created")).toBeTruthy(); 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 () => { it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => {
const listSpy = vi.spyOn(ticket, "list"); const listSpy = vi.spyOn(ticket, "list");
await ticket.create(PROJECT_ID, { await ticket.create(PROJECT_ID, {