Files
IdeA/frontend/src/features/tickets/TicketPicker.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

399 lines
14 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 { useI18n } from "@/i18n";
import { TicketFacetsBar } from "./TicketFacetsBar";
import { pickerFiltersKey } from "./ticketFilterPersistence";
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;
/**
* Whether the picker persists its filters across openings (ticket #29,
* default `true`). `initialQuery` still wins per field, so a caller imposing a
* business filter keeps priority; a caller that must never inherit stored
* preferences can opt out entirely with `false`.
*/
persistFilters?: boolean;
/**
* 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,
confirmLabel,
selectionMode = "single",
excludeRefs,
initialQuery,
persistFilters = true,
onSelect,
onClose,
}: TicketPickerProps) {
const { tr } = useI18n();
if (!open) return null;
return (
<TicketPickerBody
projectId={projectId}
title={title ?? tr("Sélectionner un ticket", "Select a ticket")}
confirmLabel={confirmLabel ?? tr("Sélectionner", "Select")}
selectionMode={selectionMode}
excludeRefs={excludeRefs}
initialQuery={initialQuery}
persistFilters={persistFilters}
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,
persistFilters,
onSelect,
onClose,
}: Required<
Pick<
TicketPickerProps,
| "projectId"
| "title"
| "confirmLabel"
| "selectionMode"
| "persistFilters"
| "onSelect"
| "onClose"
>
> &
Pick<TicketPickerProps, "excludeRefs" | "initialQuery">) {
const { tr } = useI18n();
const vm = useTicketSearch(projectId, {
initialQuery,
excludeRefs,
persistenceKey: persistFilters ? pickerFiltersKey(projectId) : undefined,
});
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={tr("Fermer le sélecteur de ticket", "Close ticket picker")}
onClick={onClose}
>
{tr("Fermer", "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>{tr("Chargement des tickets…", "Loading tickets…")}</span>
</div>
) : vm.rows.length === 0 ? (
<p className="text-sm text-muted">{tr("Aucun ticket correspondant.", "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={tr(`Sélectionner le ticket ${t.ref}`, `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()}
>
{tr("Charger plus", "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">
{tr(`${selected.size} sélectionné${selected.size > 1 ? "s" : ""}`, `${selected.size} selected`)}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
aria-label={tr("Annuler la sélection de tickets", "Cancel ticket selection")}
onClick={onClose}
>
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
aria-label={tr("Confirmer la sélection de tickets", "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>
);
}