feat(tickets): multi-sélection dans TicketPicker (#41)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:45:39 +02:00
parent 61e5e41f0d
commit daa93525d7
4 changed files with 245 additions and 61 deletions

View File

@ -209,8 +209,8 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
</ul> </ul>
)} )}
{/* Add an existing ticket via the TicketPicker popup (#19): {/* Add existing tickets via the TicketPicker popup (#19/#41):
single-select, excluding tickets already in this sprint. */} multi-select, excluding tickets already in this sprint. */}
<div className="mt-1"> <div className="mt-1">
<Button <Button
size="sm" size="sm"
@ -223,7 +223,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
> >
{addable.length === 0 {addable.length === 0
? "No tickets to add" ? "No tickets to add"
: "Ajouter un ticket"} : "Ajouter des tickets"}
</Button> </Button>
</div> </div>
</div> </div>
@ -278,24 +278,28 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
</div> </div>
)} )}
{/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above {/* ── Add-ticket picker (#19/#41) — mounts at floatingWindowNested (60),
this sprint dialog. Excludes tickets already in the target sprint. ── */} above this sprint dialog. Multi-select so several tickets can be added
in one pass; excludes tickets already in the target sprint. ── */}
<TicketPicker <TicketPicker
open={pickerSprint !== null} open={pickerSprint !== null}
projectId={projectId} projectId={projectId}
title={ title={
pickerSprint pickerSprint
? `Ajouter un ticket au sprint « ${pickerSprint.name} »` ? `Ajouter des tickets au sprint « ${pickerSprint.name} »`
: "Ajouter un ticket" : "Ajouter des tickets"
} }
selectionMode="single" confirmLabel="Ajouter au sprint"
selectionMode="multi"
excludeRefs={items excludeRefs={items
.filter((t) => t.sprintId === pickerSprint?.id) .filter((t) => t.sprintId === pickerSprint?.id)
.map((t) => t.ref)} .map((t) => t.ref)}
onSelect={(result) => { onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result; const picked = Array.isArray(result) ? result : [result];
if (picked && pickerSprint) { if (pickerSprint) {
void vm.assignSprint(picked.ref, pickerSprint.id); for (const p of picked) {
void vm.assignSprint(p.ref, pickerSprint.id);
}
} }
}} }}
onClose={() => setPickerSprint(null)} onClose={() => setPickerSprint(null)}

View File

@ -241,4 +241,108 @@ describe("TicketPicker (#18)", () => {
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
readSpy.mockRestore(); 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();
});
});
}); });

View File

@ -20,9 +20,9 @@
* (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts` * (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts`
* went into `shared/ui`. * went into `shared/ui`.
* *
* V1 implements single-select (a row click selects and closes). `"multi"` is in * Single-select: a row click resolves and closes, handing back one result.
* the frozen prop contract but not required this sprint; the surrounding * Multi-select (#41): a row click toggles a local selection set (the picker
* structure keeps it extensible. * stays open); a footer button confirms and hands back the resolved array.
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@ -119,6 +119,10 @@ function TicketPickerBody({
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [resolveError, setResolveError] = useState<string | null>(null); const [resolveError, setResolveError] = useState<string | null>(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<Set<TicketRef>>(() => new Set());
const isMulti = selectionMode === "multi";
// Focus-trap (G5): remember the previously-focused element, move focus into // 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. // the dialog on mount, keep Tab cycling inside it, and restore focus on close.
@ -159,22 +163,50 @@ function TicketPickerBody({
}; };
}, [onClose]); }, [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) { async function handlePick(ref: TicketRef) {
if (selecting) return; if (selecting) return;
setSelecting(true); setSelecting(true);
setResolveError(null); setResolveError(null);
try { try {
const result = await vm.resolve(ref); const result = await vm.resolve(ref);
// V1: single-select selects-and-closes. Multi (not required this sprint) onSelect(result);
// would accumulate a set and confirm via the footer button instead.
onSelect(selectionMode === "multi" ? [result] : result);
onClose(); onClose();
} catch (e) { } catch (e) {
setResolveError( setResolveError(describeErr(e));
e && typeof e === "object" && "message" in e setSelecting(false);
? String((e as { message: unknown }).message) }
: String(e), }
/** 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); setSelecting(false);
} }
} }
@ -243,30 +275,52 @@ function TicketPickerBody({
<p className="text-sm text-muted">No matching tickets.</p> <p className="text-sm text-muted">No matching tickets.</p>
) : ( ) : (
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{vm.rows.map((t) => ( {vm.rows.map((t) => {
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0"> const checked = selected.has(t.ref);
<button return (
type="button" <li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
aria-label={`select ticket ${t.ref}`} <button
disabled={selecting} type="button"
onClick={() => void handlePick(t.ref)} aria-label={`select ticket ${t.ref}`}
className={cn( {...(isMulti
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors", ? { role: "checkbox", "aria-checked": checked }
"hover:bg-raised focus:bg-raised focus:outline-none", : {})}
"disabled:cursor-not-allowed disabled:opacity-60", disabled={selecting}
)} onClick={() =>
> isMulti ? toggleSelected(t.ref) : void handlePick(t.ref)
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" /> }
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content"> className={cn(
{t.title} "flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
</span> "hover:bg-raised focus:bg-raised focus:outline-none",
<span className="flex shrink-0 flex-col items-end gap-1"> "disabled:cursor-not-allowed disabled:opacity-60",
<StatusBadge status={t.status} /> isMulti && checked && "bg-raised",
<PriorityBadge priority={t.priority} /> )}
</span> >
</button> {isMulti && (
</li> <span
))} aria-hidden="true"
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px] font-bold",
checked
? "border-primary bg-primary text-on-primary"
: "border-border text-transparent",
)}
>
</span>
)}
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{t.title}
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={t.status} />
<PriorityBadge priority={t.priority} />
</span>
</button>
</li>
);
})}
</ul> </ul>
)} )}
@ -284,15 +338,34 @@ function TicketPickerBody({
)} )}
</div> </div>
{/* Footer reserved for multi-select confirm (not wired in single V1). */} {/* ── Multi-select confirm footer (#41) — count + confirm; disabled
{selectionMode === "multi" && ( while empty or resolving. Single-select has no footer. ── */}
<footer className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3"> {isMulti && (
<Button size="sm" variant="ghost" onClick={onClose}> <footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3">
Annuler <span className="text-xs text-muted" aria-live="polite">
</Button> {selected.size} sélectionné{selected.size > 1 ? "s" : ""}
<Button size="sm" disabled> </span>
{confirmLabel} <div className="flex items-center gap-2">
</Button> <Button
size="sm"
variant="ghost"
aria-label="cancel ticket selection"
onClick={onClose}
>
Annuler
</Button>
<Button
size="sm"
aria-label="confirm ticket selection"
loading={selecting}
disabled={selected.size === 0 || selecting}
onClick={() => void handleConfirm()}
>
{selected.size > 0
? `${confirmLabel} (${selected.size})`
: confirmLabel}
</Button>
</div>
</footer> </footer>
)} )}
</div> </div>

View File

@ -896,23 +896,26 @@ describe("SprintManager (#11)", () => {
expect(fresh.title).toBe("Kept"); expect(fresh.title).toBe("Kept");
}); });
it("adds a backlog ticket to a sprint via the TicketPicker popup (#19)", async () => { it("adds several backlog tickets to a sprint in one pass via the multi picker (#19/#41)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" }); ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const t = await ticket.create(PROJECT_ID, { title: "Backlog item" }); const t1 = await ticket.create(PROJECT_ID, { title: "Backlog item" });
const t2 = await ticket.create(PROJECT_ID, { title: "Backlog item 2" });
const dialog = await openManager(); const dialog = await openManager();
// Open the add-ticket picker for the Target sprint. // Open the add-ticket picker for the Target sprint (multi-select).
fireEvent.click( fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"), await within(dialog).findByLabelText("add ticket to sprint Target"),
); );
// The picker popup lists the backlog ticket — select it. // The picker toggles rows without assigning; confirm applies the batch.
fireEvent.click(await screen.findByLabelText(`select ticket ${t.ref}`)); fireEvent.click(await screen.findByLabelText(`select ticket ${t1.ref}`));
fireEvent.click(await screen.findByLabelText(`select ticket ${t2.ref}`));
fireEvent.click(screen.getByLabelText("confirm ticket selection"));
await waitFor(async () => { await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref); expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
expect(fresh.sprintId).toBe("s1"); expect((await ticket.read(PROJECT_ID, t2.ref)).sprintId).toBe("s1");
}); });
}); });