feat(tickets): fenêtre dédiée de gestion des tickets — liens via popup + assistant IA flottant (#17)
Lot C du sprint UI rework : fenêtre dédiée de gestion des tickets, appuyée sur la primitive FloatingWindow (#16) et la popup TicketPicker (#18). - TicketDetail : gestion des liens de ticket via la popup TicketPicker, assistant IA en fenêtre flottante - FloatingWindow : ajustements pour l'usage fenêtre dédiée Tests : tsc --noEmit clean, suite complète 530/530, suites tickets + FloatingWindow 50/50. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -12,10 +12,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { TicketLinkKind } from "@/domain";
|
||||
import { Button, Input, Spinner, cn } from "@/shared";
|
||||
import { Button, FloatingWindow, Input, Spinner, cn } from "@/shared";
|
||||
import { useTicketDetail } from "./useTicketDetail";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||
import { TicketPicker } from "./TicketPicker";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -98,7 +99,6 @@ export function TicketDetail({
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [carnet, setCarnet] = useState("");
|
||||
const [linkTarget, setLinkTarget] = useState("");
|
||||
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
|
||||
const [assignPick, setAssignPick] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -106,6 +106,11 @@ export function TicketDetail({
|
||||
const [confirmClose, setConfirmClose] = useState(false);
|
||||
// Confirmation shown before deleting the ticket (#6).
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// Add-link TicketPicker open state (#17): replaces the manual `#id` input.
|
||||
const [showLinkPicker, setShowLinkPicker] = useState(false);
|
||||
// AI writing-assistant window open state (#17): opened from the bottom-right
|
||||
// floating button so the assistant is an obvious, first-class affordance.
|
||||
const [showAssistant, setShowAssistant] = useState(false);
|
||||
|
||||
// The ticket was deleted (by this view or another actor): close the surface.
|
||||
// The removal from lists flows from the same `issueDeleted` event (#6).
|
||||
@ -187,14 +192,14 @@ export function TicketDetail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`ticket ${ticketRef}`}
|
||||
<FloatingWindow
|
||||
open
|
||||
title={`ticket ${ticketRef}`}
|
||||
size="xl"
|
||||
onClose={requestClose}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||
{/* ── Toolbar (badges + ticket-level actions) ── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TicketRef ticketRef={ticketRef} />
|
||||
{t && <StatusBadge status={t.status} />}
|
||||
@ -223,16 +228,13 @@ export function TicketDetail({
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={requestClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{vm.conflict && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
|
||||
className="border-b border-warning/40 bg-warning/10 px-1 py-2 text-sm text-warning"
|
||||
>
|
||||
This ticket was modified elsewhere and reloaded. Re-apply your change.
|
||||
</p>
|
||||
@ -240,23 +242,23 @@ export function TicketDetail({
|
||||
{vm.error && !vm.conflict && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger"
|
||||
className="border-b border-danger/40 bg-danger/10 px-1 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.busy && !t ? (
|
||||
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted">
|
||||
<div className="flex items-center gap-2 py-5 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading ticket…</span>
|
||||
</div>
|
||||
) : !t ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted">
|
||||
Ticket not found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="-mx-4">
|
||||
{/* ── Fields (F3) ── */}
|
||||
<Section title="Details">
|
||||
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
||||
@ -421,6 +423,8 @@ export function TicketDetail({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Add a link via the TicketPicker popup (#17): the kind is chosen
|
||||
here, the target ticket is selected in the popup (no manual #id). */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="link kind"
|
||||
@ -435,23 +439,11 @@ export function TicketDetail({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
aria-label="link target ref"
|
||||
placeholder="#id"
|
||||
className="w-24"
|
||||
value={linkTarget}
|
||||
onChange={(e) => setLinkTarget(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!linkTarget.trim() || vm.busy}
|
||||
onClick={async () => {
|
||||
const target = linkTarget.trim().startsWith("#")
|
||||
? linkTarget.trim()
|
||||
: `#${linkTarget.trim()}`;
|
||||
const ok = await vm.link(target, linkKind);
|
||||
if (ok) setLinkTarget("");
|
||||
}}
|
||||
aria-label="add link"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setShowLinkPicker(true)}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
@ -518,16 +510,48 @@ export function TicketDetail({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── AI assistant chat (#8, F2) ── */}
|
||||
<Section
|
||||
title="Assistant IA"
|
||||
hint="Conversez avec un agent IA pour faire évoluer ce ticket. L'agent lit le projet en lecture seule et ne peut éditer que ce ticket."
|
||||
>
|
||||
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
||||
</Section>
|
||||
{/* ── Add-link picker (#17) — nested (token 60) over this window ── */}
|
||||
<TicketPicker
|
||||
open={showLinkPicker}
|
||||
projectId={projectId}
|
||||
title="Lier un ticket"
|
||||
selectionMode="single"
|
||||
excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
|
||||
onSelect={(result) => {
|
||||
const picked = Array.isArray(result) ? result[0] : result;
|
||||
if (picked) void vm.link(picked.ref, linkKind);
|
||||
setShowLinkPicker(false);
|
||||
}}
|
||||
onClose={() => setShowLinkPicker(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Floating "Assistant IA" button (#17), bottom-right of the window.
|
||||
Made prominent per the explicit user request; opens the ticket-writing
|
||||
AI assistant in a nested window. Sticks to the window's bottom edge. ── */}
|
||||
{t && (
|
||||
<div className="pointer-events-none sticky bottom-0 z-10 -mx-4 -mb-4 flex justify-end px-4 pb-4 pt-2">
|
||||
<Button
|
||||
aria-label="ouvrir l'assistant IA"
|
||||
className="pointer-events-auto shadow-lg"
|
||||
onClick={() => setShowAssistant(true)}
|
||||
>
|
||||
🤖 Assistant IA
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI writing assistant, in a nested window (token 60) above this one. */}
|
||||
<FloatingWindow
|
||||
open={showAssistant}
|
||||
title={`Assistant IA — ${ticketRef}`}
|
||||
size="lg"
|
||||
onClose={() => setShowAssistant(false)}
|
||||
>
|
||||
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* ── Unsaved-changes confirmation on close (#9) ── */}
|
||||
{confirmClose && (
|
||||
<div
|
||||
@ -623,6 +647,6 @@ export function TicketDetail({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@ -645,47 +645,96 @@ describe("TicketsView", () => {
|
||||
);
|
||||
|
||||
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
||||
expect(within(detail).getByText("Assistant IA")).toBeTruthy();
|
||||
|
||||
const profileSelect = await within(detail).findByLabelText(
|
||||
// The assistant is opened from the prominent bottom-right floating button;
|
||||
// it is not inline anymore (#17).
|
||||
expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
|
||||
fireEvent.click(within(detail).getByLabelText("ouvrir l'assistant IA"));
|
||||
|
||||
// It opens in its own nested window.
|
||||
const assistant = await screen.findByRole("dialog", {
|
||||
name: `Assistant IA — ${t.ref}`,
|
||||
});
|
||||
|
||||
const profileSelect = await within(assistant).findByLabelText(
|
||||
"profil de l'assistant",
|
||||
);
|
||||
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||
|
||||
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
|
||||
fireEvent.click(within(detail).getByText("Ouvrir la conversation"));
|
||||
fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));
|
||||
|
||||
expect(
|
||||
await within(detail).findByLabelText("message à l'assistant"),
|
||||
await within(assistant).findByLabelText("message à l'assistant"),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(detail).getByLabelText("message à l'assistant"), {
|
||||
fireEvent.change(within(assistant).getByLabelText("message à l'assistant"), {
|
||||
target: { value: "Crée un plan de correction" },
|
||||
});
|
||||
fireEvent.click(within(detail).getByText("Envoyer"));
|
||||
fireEvent.click(within(assistant).getByText("Envoyer"));
|
||||
|
||||
expect(await within(detail).findByText("Crée un plan de correction")).toBeTruthy();
|
||||
expect(
|
||||
await within(detail).findByText(
|
||||
await within(assistant).findByText("Crée un plan de correction"),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await within(assistant).findByText(
|
||||
"Assistant: reçu « Crée un plan de correction ».",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(detail).getByText("Fermer la conversation"));
|
||||
fireEvent.click(within(assistant).getByText("Fermer la conversation"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(detail).queryByLabelText("message à l'assistant"),
|
||||
within(assistant).queryByLabelText("message à l'assistant"),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||
expect(
|
||||
within(detail).queryByText("Crée un plan de correction"),
|
||||
within(assistant).queryByText("Crée un plan de correction"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(detail).queryByText("Assistant: reçu « Crée un plan de correction »."),
|
||||
within(assistant).queryByText(
|
||||
"Assistant: reçu « Crée un plan de correction ».",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("adds a link via the TicketPicker popup instead of a manual #id (#17)", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "Source ticket" });
|
||||
const b = await ticket.create(PROJECT_ID, { title: "Target ticket" });
|
||||
const gateways = { ticket, system, agent } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketDetail
|
||||
projectId={PROJECT_ID}
|
||||
ticketRef={a.ref}
|
||||
onClose={() => {}}
|
||||
onOpenRef={() => {}}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const detail = await screen.findByRole("dialog", { name: `ticket ${a.ref}` });
|
||||
// The old manual "#id" input is gone.
|
||||
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
|
||||
|
||||
// Choose the link kind, then open the picker and select the target ticket.
|
||||
fireEvent.change(within(detail).getByLabelText("link kind"), {
|
||||
target: { value: "blocks" },
|
||||
});
|
||||
fireEvent.click(within(detail).getByLabelText("add link"));
|
||||
|
||||
// The picker excludes the ticket itself; the target is offered.
|
||||
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
|
||||
fireEvent.click(await screen.findByLabelText(`select ticket ${b.ref}`));
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, a.ref);
|
||||
expect(fresh.links).toEqual([{ targetRef: b.ref, kind: "blocks" }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SprintManager (#11)", () => {
|
||||
|
||||
@ -7,6 +7,41 @@ import { MenuBar } from "./MenuBar";
|
||||
import { zIndex } from "./zIndex";
|
||||
|
||||
describe("FloatingWindow (#16, G1)", () => {
|
||||
it("Escape closes only the topmost window when stacked (#17)", () => {
|
||||
const onCloseOuter = vi.fn();
|
||||
const onCloseInner = vi.fn();
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
|
||||
<p>outer body</p>
|
||||
</FloatingWindow>
|
||||
<FloatingWindow open title="Inner" onClose={onCloseInner}>
|
||||
<p>inner body</p>
|
||||
</FloatingWindow>
|
||||
</>,
|
||||
);
|
||||
|
||||
// The inner (last-mounted) window is topmost: Escape closes only it.
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onCloseInner).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseOuter).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount the inner window; the outer becomes topmost and now handles Escape.
|
||||
rerender(
|
||||
<>
|
||||
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
|
||||
<p>outer body</p>
|
||||
</FloatingWindow>
|
||||
<FloatingWindow open={false} title="Inner" onClose={onCloseInner}>
|
||||
<p>inner body</p>
|
||||
</FloatingWindow>
|
||||
</>,
|
||||
);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onCloseOuter).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseInner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const { container } = render(
|
||||
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}>
|
||||
|
||||
@ -34,6 +34,13 @@ const SIZE_CLASS: Record<FloatingWindowSize, string> = {
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Stack of currently-mounted windows (most-recent last). Only the topmost window
|
||||
* reacts to Escape and traps Tab, so a window opened over another (e.g. a ticket
|
||||
* detail over the tickets list) doesn't close/steal from the one beneath it.
|
||||
*/
|
||||
const windowStack: object[] = [];
|
||||
|
||||
export interface FloatingWindowProps {
|
||||
/** Whether the window is shown. Rendered as `null` when closed. */
|
||||
open: boolean;
|
||||
@ -76,11 +83,17 @@ function FloatingWindowBody({
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const node = dialogRef.current;
|
||||
// Register on the window stack so only the topmost handles keys.
|
||||
const token = {};
|
||||
windowStack.push(token);
|
||||
const isTopmost = () => windowStack[windowStack.length - 1] === token;
|
||||
// Move focus into the window (first focusable, else the dialog itself).
|
||||
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? node)?.focus();
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// Ignore keys while a later window is stacked on top of this one.
|
||||
if (!isTopmost()) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
@ -111,6 +124,8 @@ function FloatingWindowBody({
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
const i = windowStack.indexOf(token);
|
||||
if (i >= 0) windowStack.splice(i, 1);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
Reference in New Issue
Block a user