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:
2026-07-06 15:54:47 +02:00
parent d97abb116f
commit 140daae8b3
8 changed files with 954 additions and 65 deletions

View File

@ -0,0 +1,201 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock";
import type { Ticket } from "@/domain";
import type { Gateways } from "@/ports";
import { TicketPicker, type TicketPickerResult } from "./TicketPicker";
const PROJECT_ID = "project-picker-test";
function renderPicker(
ticket: MockTicketGateway,
props: Partial<React.ComponentProps<typeof TicketPicker>> = {},
) {
const onSelect = vi.fn();
const onClose = vi.fn();
const gateways = { ticket } as unknown as Gateways;
const utils = render(
<DIProvider gateways={gateways}>
<TicketPicker
open
projectId={PROJECT_ID}
onSelect={onSelect}
onClose={onClose}
{...props}
/>
</DIProvider>,
);
return { ...utils, onSelect, onClose };
}
/** Seeds three tickets with distinct status/priority for filter assertions. */
async function seedTrio(ticket: MockTicketGateway): Promise<Ticket[]> {
const a = await ticket.create(PROJECT_ID, {
title: "Alpha bug",
status: "open",
priority: "high",
});
const b = await ticket.create(PROJECT_ID, {
title: "Bravo task",
priority: "low",
});
await ticket.update(PROJECT_ID, b.ref, {
status: "closed",
expectedVersion: b.version,
});
const c = await ticket.create(PROJECT_ID, {
title: "Charlie chore",
status: "QA",
priority: "medium",
});
return [a, b, c];
}
describe("TicketPicker (#18)", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
});
it("renders nothing when closed", () => {
const gateways = { ticket } as unknown as Gateways;
const { container } = render(
<DIProvider gateways={gateways}>
<TicketPicker
open={false}
projectId={PROJECT_ID}
onSelect={vi.fn()}
onClose={vi.fn()}
/>
</DIProvider>,
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it("lists tickets and single-select hands back the full result + closes", async () => {
const [a] = await seedTrio(ticket);
const { onSelect, onClose } = renderPicker(ticket);
// Rows appear.
await screen.findByText("Alpha bug");
expect(screen.getByText("Bravo task")).toBeTruthy();
expect(screen.getByText("Charlie chore")).toBeTruthy();
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
const result = onSelect.mock.calls[0][0] as TicketPickerResult;
expect(result).toMatchObject({
ref: a.ref,
id: a.id,
number: a.number,
title: "Alpha bug",
status: "open",
priority: "high",
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("hides excludeRefs client-side", async () => {
const [a, b] = await seedTrio(ticket);
renderPicker(ticket, { excludeRefs: [a.ref] });
await screen.findByText("Bravo task");
expect(screen.queryByText("Alpha bug")).toBeNull();
// Excluded ref cannot be selected either.
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
});
it("filters by status facet (OR within, AND across)", async () => {
await seedTrio(ticket);
renderPicker(ticket);
await screen.findByText("Alpha bug");
// Tick "open" ⇒ only Alpha (open) remains; Bravo (closed) & Charlie (QA) go.
fireEvent.click(screen.getByLabelText("filter status Open"));
await waitFor(() => expect(screen.queryByText("Bravo task")).toBeNull());
expect(screen.getByText("Alpha bug")).toBeTruthy();
expect(screen.queryByText("Charlie chore")).toBeNull();
// Add priority "low" ⇒ (open) AND (low) ⇒ nothing (Alpha is high).
fireEvent.click(screen.getByLabelText("filter priority Low"));
await waitFor(() =>
expect(screen.getByText("No matching tickets.")).toBeTruthy(),
);
// Clear facets ⇒ all three back.
fireEvent.click(screen.getByLabelText("clear filters"));
await waitFor(() => expect(screen.getByText("Charlie chore")).toBeTruthy());
expect(screen.getByText("Alpha bug")).toBeTruthy();
expect(screen.getByText("Bravo task")).toBeTruthy();
});
it("debounced text search narrows the list", async () => {
await seedTrio(ticket);
renderPicker(ticket);
await screen.findByText("Alpha bug");
fireEvent.change(screen.getByLabelText("search tickets"), {
target: { value: "charlie" },
});
// Wait on the removal (all three are visible until the debounced refetch).
await waitFor(() => expect(screen.queryByText("Alpha bug")).toBeNull());
expect(screen.getByText("Charlie chore")).toBeTruthy();
expect(screen.queryByText("Bravo task")).toBeNull();
});
it("honors initialQuery as a seed filter", async () => {
await seedTrio(ticket);
renderPicker(ticket, { initialQuery: { statuses: ["QA"] } });
await screen.findByText("Charlie chore");
expect(screen.queryByText("Alpha bug")).toBeNull();
expect(screen.queryByText("Bravo task")).toBeNull();
});
it("closes on Escape and on backdrop click", async () => {
await seedTrio(ticket);
const { onClose } = renderPicker(ticket);
await screen.findByText("Alpha bug");
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
// Backdrop (the outermost fixed overlay) closes on mousedown.
const dialog = screen.getByRole("dialog");
const backdrop = dialog.parentElement as HTMLElement;
fireEvent.mouseDown(backdrop);
expect(onClose).toHaveBeenCalledTimes(2);
});
it("uses a custom title and surfaces a resolve failure without closing", async () => {
const [a] = await seedTrio(ticket);
const readSpy = vi
.spyOn(ticket, "read")
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "gone" });
const { onSelect, onClose } = renderPicker(ticket, {
title: "Choisir un ticket à lier",
});
await screen.findByText("Alpha bug");
expect(
screen.getByRole("dialog", { name: "Choisir un ticket à lier" }),
).toBeTruthy();
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("gone"));
expect(onSelect).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
readSpy.mockRestore();
});
});