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" && (
-