feat(tickets): popup réutilisable de sélection de ticket (#18)
Ajoute TicketPicker, popup modale réutilisable de sélection de ticket, socle du chemin critique du sprint UI rework (#17 et #19 en dépendent). - TicketPicker.tsx : popup de recherche/sélection réutilisable - useTicketSearch.ts : hook de recherche/filtrage extrait et partagé - TicketFacetsBar.tsx : barre de facettes extraite de TicketsPanel pour réutilisation (listing principal + picker) - shared/ui/zIndex.ts : échelle z-index centralisée - TicketsPanel : consomme la barre de facettes extraite Tests : TicketPicker.test.tsx 8/8, suite tickets 37/37, tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
299
frontend/src/features/tickets/TicketPicker.tsx
Normal file
299
frontend/src/features/tickets/TicketPicker.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
// 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]);
|
||||
|
||||
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);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setResolveError(
|
||||
e && typeof e === "object" && "message" in e
|
||||
? String((e as { message: unknown }).message)
|
||||
: String(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}
|
||||
/>
|
||||
</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) => (
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user