From daa93525d720d40b9a8107d665b3607524397099 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 12 Jul 2026 19:45:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20multi-s=C3=A9lection=20dans=20?= =?UTF-8?q?TicketPicker=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le TicketPicker permet la sélection multiple de tickets ; SprintManager consomme la sélection multiple. Frontend-pur. QA vert : tsc --noEmit exit 0, vitest 62 fichiers / 620 tests passés. Co-Authored-By: Claude Opus 4.8 --- .../src/features/tickets/SprintManager.tsx | 26 +-- .../features/tickets/TicketPicker.test.tsx | 104 ++++++++++++ .../src/features/tickets/TicketPicker.tsx | 159 +++++++++++++----- .../src/features/tickets/tickets.test.tsx | 17 +- 4 files changed, 245 insertions(+), 61 deletions(-) diff --git a/frontend/src/features/tickets/SprintManager.tsx b/frontend/src/features/tickets/SprintManager.tsx index dd64e73..ec333e8 100644 --- a/frontend/src/features/tickets/SprintManager.tsx +++ b/frontend/src/features/tickets/SprintManager.tsx @@ -209,8 +209,8 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) { )} - {/* Add an existing ticket via the TicketPicker popup (#19): - single-select, excluding tickets already in this sprint. */} + {/* Add existing tickets via the TicketPicker popup (#19/#41): + multi-select, excluding tickets already in this sprint. */}
@@ -278,24 +278,28 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) { )} - {/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above - this sprint dialog. Excludes tickets already in the target sprint. ── */} + {/* ── Add-ticket picker (#19/#41) — mounts at floatingWindowNested (60), + above this sprint dialog. Multi-select so several tickets can be added + in one pass; excludes tickets already in the target sprint. ── */} t.sprintId === pickerSprint?.id) .map((t) => t.ref)} onSelect={(result) => { - const picked = Array.isArray(result) ? result[0] : result; - if (picked && pickerSprint) { - void vm.assignSprint(picked.ref, pickerSprint.id); + const picked = Array.isArray(result) ? result : [result]; + if (pickerSprint) { + for (const p of picked) { + void vm.assignSprint(p.ref, pickerSprint.id); + } } }} onClose={() => setPickerSprint(null)} diff --git a/frontend/src/features/tickets/TicketPicker.test.tsx b/frontend/src/features/tickets/TicketPicker.test.tsx index f49c978..207953c 100644 --- a/frontend/src/features/tickets/TicketPicker.test.tsx +++ b/frontend/src/features/tickets/TicketPicker.test.tsx @@ -241,4 +241,108 @@ describe("TicketPicker (#18)", () => { expect(onClose).not.toHaveBeenCalled(); readSpy.mockRestore(); }); + + describe("multi-select (#41)", () => { + it("toggles rows without closing and confirms the resolved array", async () => { + const [a, , c] = await seedTrio(ticket); + const { onSelect, onClose } = renderPicker(ticket, { + selectionMode: "multi", + confirmLabel: "Ajouter au sprint", + }); + + await screen.findByText("Alpha bug"); + + const confirm = screen.getByLabelText( + "confirm ticket selection", + ) as HTMLButtonElement; + // Empty selection ⇒ confirm disabled, count reads 0. + expect(confirm.disabled).toBe(true); + expect(screen.getByText("0 sélectionné")).toBeTruthy(); + + // Select two rows; the picker stays open and does not fire onSelect yet. + fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); + fireEvent.click(screen.getByLabelText(`select ticket ${c.ref}`)); + expect(onSelect).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + + // Visual state: the toggled rows expose aria-checked. + expect( + screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"), + ).toBe("true"); + expect(screen.getByText("2 sélectionnés")).toBeTruthy(); + expect(confirm.textContent).toContain("2"); + expect(confirm.disabled).toBe(false); + + // Toggle one back off ⇒ count drops, still open. + fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); + expect(screen.getByText("1 sélectionné")).toBeTruthy(); + expect( + screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"), + ).toBe("false"); + + // Re-select and confirm ⇒ onSelect receives the resolved array, then close. + fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); + fireEvent.click(confirm); + + await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); + const results = onSelect.mock.calls[0][0] as TicketPickerResult[]; + expect(Array.isArray(results)).toBe(true); + expect(results.map((r) => r.ref).sort()).toEqual([a.ref, c.ref].sort()); + expect(results).toContainEqual( + expect.objectContaining({ ref: a.ref, id: a.id, number: a.number }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("confirm stays disabled when selection is empty", async () => { + await seedTrio(ticket); + const { onSelect } = renderPicker(ticket, { selectionMode: "multi" }); + await screen.findByText("Alpha bug"); + + const confirm = screen.getByLabelText( + "confirm ticket selection", + ) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + // Clicking a disabled button is a no-op. + fireEvent.click(confirm); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("respects excludeRefs and facet filters in multi mode", async () => { + const [a, b] = await seedTrio(ticket); + renderPicker(ticket, { + selectionMode: "multi", + excludeRefs: [a.ref], + }); + + // Excluded ref is not selectable. + await screen.findByText("Bravo task"); + expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull(); + expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy(); + + // Facets still narrow the list (Open ⇒ only Alpha, which is excluded ⇒ none). + fireEvent.click(screen.getByLabelText("filter status Open")); + await waitFor(() => + expect(screen.getByText("No matching tickets.")).toBeTruthy(), + ); + }); + + it("closes on Escape and backdrop without confirming", async () => { + const [a] = await seedTrio(ticket); + const { onSelect, onClose } = renderPicker(ticket, { + selectionMode: "multi", + }); + await screen.findByText("Alpha bug"); + fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + + const backdrop = screen.getByRole("dialog").parentElement as HTMLElement; + fireEvent.mouseDown(backdrop); + expect(onClose).toHaveBeenCalledTimes(2); + expect(onSelect).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/features/tickets/TicketPicker.tsx b/frontend/src/features/tickets/TicketPicker.tsx index d77333d..adb0892 100644 --- a/frontend/src/features/tickets/TicketPicker.tsx +++ b/frontend/src/features/tickets/TicketPicker.tsx @@ -20,9 +20,9 @@ * (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts` * went into `shared/ui`. * - * V1 implements single-select (a row click selects and closes). `"multi"` is in - * the frozen prop contract but not required this sprint; the surrounding - * structure keeps it extensible. + * Single-select: a row click resolves and closes, handing back one result. + * Multi-select (#41): a row click toggles a local selection set (the picker + * stays open); a footer button confirms and hands back the resolved array. */ import { useEffect, useRef, useState } from "react"; @@ -119,6 +119,10 @@ function TicketPickerBody({ const dialogRef = useRef(null); const [selecting, setSelecting] = useState(false); const [resolveError, setResolveError] = useState(null); + // Multi-select (#41): the locally-accumulated selection. Persists across + // filter/search changes so the user can build a batch from several queries. + const [selected, setSelected] = useState>(() => new Set()); + const isMulti = selectionMode === "multi"; // Focus-trap (G5): remember the previously-focused element, move focus into // the dialog on mount, keep Tab cycling inside it, and restore focus on close. @@ -159,22 +163,50 @@ function TicketPickerBody({ }; }, [onClose]); + function describeErr(e: unknown): string { + return e && typeof e === "object" && "message" in e + ? String((e as { message: unknown }).message) + : String(e); + } + + /** Multi-select: toggle a row in/out of the local selection (never closes). */ + function toggleSelected(ref: TicketRef) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(ref)) next.delete(ref); + else next.add(ref); + return next; + }); + } + + /** Single-select: resolve the clicked row and hand it back, then close. */ async function handlePick(ref: TicketRef) { if (selecting) return; setSelecting(true); setResolveError(null); try { const result = await vm.resolve(ref); - // V1: single-select selects-and-closes. Multi (not required this sprint) - // would accumulate a set and confirm via the footer button instead. - onSelect(selectionMode === "multi" ? [result] : result); + onSelect(result); onClose(); } catch (e) { - setResolveError( - e && typeof e === "object" && "message" in e - ? String((e as { message: unknown }).message) - : String(e), + setResolveError(describeErr(e)); + setSelecting(false); + } + } + + /** Multi-select: resolve every selected ref and hand back the array. */ + async function handleConfirm() { + if (selecting || selected.size === 0) return; + setSelecting(true); + setResolveError(null); + try { + const results = await Promise.all( + Array.from(selected).map((ref) => vm.resolve(ref)), ); + onSelect(results); + onClose(); + } catch (e) { + setResolveError(describeErr(e)); setSelecting(false); } } @@ -243,30 +275,52 @@ function TicketPickerBody({

No matching tickets.

) : (
    - {vm.rows.map((t) => ( -
  • - -
  • - ))} + {vm.rows.map((t) => { + const checked = selected.has(t.ref); + return ( +
  • + +
  • + ); + })}
)} @@ -284,15 +338,34 @@ function TicketPickerBody({ )} - {/* Footer reserved for multi-select confirm (not wired in single V1). */} - {selectionMode === "multi" && ( -