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:
@ -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<HTMLDivElement>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
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
|
||||
// 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({
|
||||
<p className="text-sm text-muted">No matching tickets.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.rows.map((t) => (
|
||||
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`select ticket ${t.ref}`}
|
||||
disabled={selecting}
|
||||
onClick={() => void handlePick(t.ref)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{vm.rows.map((t) => {
|
||||
const checked = selected.has(t.ref);
|
||||
return (
|
||||
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`select ticket ${t.ref}`}
|
||||
{...(isMulti
|
||||
? { role: "checkbox", "aria-checked": checked }
|
||||
: {})}
|
||||
disabled={selecting}
|
||||
onClick={() =>
|
||||
isMulti ? toggleSelected(t.ref) : void handlePick(t.ref)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
isMulti && checked && "bg-raised",
|
||||
)}
|
||||
>
|
||||
{isMulti && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -284,15 +338,34 @@ function TicketPickerBody({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer reserved for multi-select confirm (not wired in single V1). */}
|
||||
{selectionMode === "multi" && (
|
||||
<footer className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button size="sm" disabled>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
{/* ── Multi-select confirm footer (#41) — count + confirm; disabled
|
||||
while empty or resolving. Single-select has no footer. ── */}
|
||||
{isMulti && (
|
||||
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3">
|
||||
<span className="text-xs text-muted" aria-live="polite">
|
||||
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user