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>
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
/**
|
|
* Reusable ticket-selection popup (ticket #18, G3).
|
|
*
|
|
* A modal picker consumed by any feature that needs the user to choose a ticket
|
|
* (sprint composition #19, ticket linking #17, …). It reuses the shared
|
|
* {@link TicketFacetsBar} (search + status/priority facets) and the slim
|
|
* {@link useTicketSearch} hook, so filtering/search behave exactly like the main
|
|
* listing without dragging in its sprint grouping or event subscriptions.
|
|
*
|
|
* Integration rules (sprint scoping note `ui-rework-sprint-scoping-contracts`):
|
|
* - G5: mount at the **chrome level** (ProjectsView/App), never inside
|
|
* LayoutGrid/LeafView. A focus-trap is mandatory because xterm captures
|
|
* keyboard focus in the workspace.
|
|
* - z-index `floatingWindowNested` (60): the picker is opened from within other
|
|
* floating windows, so it must sit above them.
|
|
*
|
|
* ⚠️ Location note: this lives under `features/tickets` (not `shared/ui`) because
|
|
* it depends on the ticket domain + DI gateways, whereas `shared/ui` is the pure
|
|
* design system (Button/Input/…). This matches every other feature overlay
|
|
* (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts`
|
|
* went into `shared/ui`.
|
|
*
|
|
* 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";
|
|
|
|
import type { TicketRef } from "@/domain";
|
|
import type { TicketListQuery } from "@/ports";
|
|
import { Button, Spinner, cn, zIndex } from "@/shared";
|
|
import { TicketFacetsBar } from "./TicketFacetsBar";
|
|
import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta";
|
|
import {
|
|
useTicketSearch,
|
|
type TicketPickerResult,
|
|
} from "./useTicketSearch";
|
|
|
|
export type { TicketPickerResult } from "./useTicketSearch";
|
|
|
|
export type TicketPickerSelectionMode = "single" | "multi";
|
|
|
|
export interface TicketPickerProps {
|
|
/** Whether the popup is shown. Rendered as `null` when closed. */
|
|
open: boolean;
|
|
projectId: string;
|
|
/** Dialog heading (default "Sélectionner un ticket"). */
|
|
title?: string;
|
|
/** Confirm-button label (multi-select only; unused in single-select V1). */
|
|
confirmLabel?: string;
|
|
/** Selection mode (default "single"). */
|
|
selectionMode?: TicketPickerSelectionMode;
|
|
/** Refs to hide from the results (e.g. the current ticket, already-linked). */
|
|
excludeRefs?: TicketRef[];
|
|
/** Seed filter/search query. */
|
|
initialQuery?: TicketListQuery;
|
|
/**
|
|
* Selection callback. In `"single"` mode it receives one
|
|
* {@link TicketPickerResult}; in `"multi"` it receives the array.
|
|
*/
|
|
onSelect: (result: TicketPickerResult | TicketPickerResult[]) => 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 TicketPicker({
|
|
open,
|
|
projectId,
|
|
title = "Sélectionner un ticket",
|
|
confirmLabel = "Sélectionner",
|
|
selectionMode = "single",
|
|
excludeRefs,
|
|
initialQuery,
|
|
onSelect,
|
|
onClose,
|
|
}: TicketPickerProps) {
|
|
if (!open) return null;
|
|
return (
|
|
<TicketPickerBody
|
|
projectId={projectId}
|
|
title={title}
|
|
confirmLabel={confirmLabel}
|
|
selectionMode={selectionMode}
|
|
excludeRefs={excludeRefs}
|
|
initialQuery={initialQuery}
|
|
onSelect={onSelect}
|
|
onClose={onClose}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The mounted picker. Split from {@link TicketPicker} so the hook (and its
|
|
* fetch) only run while the popup is actually open — mounting/unmounting on
|
|
* `open` resets state cleanly between openings.
|
|
*/
|
|
function TicketPickerBody({
|
|
projectId,
|
|
title,
|
|
confirmLabel,
|
|
selectionMode,
|
|
excludeRefs,
|
|
initialQuery,
|
|
onSelect,
|
|
onClose,
|
|
}: Required<
|
|
Pick<
|
|
TicketPickerProps,
|
|
"projectId" | "title" | "confirmLabel" | "selectionMode" | "onSelect" | "onClose"
|
|
>
|
|
> &
|
|
Pick<TicketPickerProps, "excludeRefs" | "initialQuery">) {
|
|
const vm = useTicketSearch(projectId, { initialQuery, excludeRefs });
|
|
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.
|
|
useEffect(() => {
|
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
const node = dialogRef.current;
|
|
// Focus the first focusable control (the search input) once mounted.
|
|
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]);
|
|
|
|
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);
|
|
onSelect(result);
|
|
onClose();
|
|
} catch (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);
|
|
}
|
|
}
|
|
|
|
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-lg 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 ticket picker"
|
|
onClick={onClose}
|
|
>
|
|
Close
|
|
</Button>
|
|
</header>
|
|
|
|
{/* ── Filters ── */}
|
|
<div className="shrink-0 border-b border-border px-4 py-3">
|
|
<TicketFacetsBar
|
|
text={vm.text}
|
|
onTextChange={vm.setText}
|
|
statuses={vm.statuses}
|
|
priorities={vm.priorities}
|
|
onToggleStatus={vm.toggleStatus}
|
|
onTogglePriority={vm.togglePriority}
|
|
onClearFacets={vm.clearFacets}
|
|
sort={vm.sort}
|
|
onSortChange={vm.setSort}
|
|
/>
|
|
</div>
|
|
|
|
{(vm.error || resolveError) && (
|
|
<p
|
|
role="alert"
|
|
className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger"
|
|
>
|
|
{resolveError ?? vm.error}
|
|
</p>
|
|
)}
|
|
|
|
{/* ── Results ── */}
|
|
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
|
|
{vm.busy && vm.rows.length === 0 ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted">
|
|
<Spinner size={14} />
|
|
<span>Loading tickets…</span>
|
|
</div>
|
|
) : vm.rows.length === 0 ? (
|
|
<p className="text-sm text-muted">No matching tickets.</p>
|
|
) : (
|
|
<ul className="flex flex-col divide-y divide-border">
|
|
{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>
|
|
)}
|
|
|
|
{vm.hasMore && (
|
|
<div className="mt-3 flex justify-center">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
loading={vm.busy}
|
|
onClick={() => vm.loadMore()}
|
|
>
|
|
Load more
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 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>
|
|
</div>
|
|
);
|
|
}
|