Merge feature/sprint-ticket-picker into develop

Lot D du sprint UI rework : TicketPicker branché dans la gestion de sprint (#19).
Tests verts (tsc clean, suite complète 528/528, tickets 38/38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 21:01:16 +02:00
3 changed files with 78 additions and 32 deletions

View File

@ -13,22 +13,18 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Input, cn } from "@/shared"; import { Button, Input } from "@/shared";
import { TicketRef } from "./ticketMeta"; import { TicketRef } from "./ticketMeta";
import { TicketPicker } from "./TicketPicker";
import type { TicketsViewModel } from "./useTickets"; import type { TicketsViewModel } from "./useTickets";
const selectClass = cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
export interface SprintManagerProps { export interface SprintManagerProps {
projectId: string;
vm: TicketsViewModel; vm: TicketsViewModel;
onClose: () => void; onClose: () => void;
} }
export function SprintManager({ vm, onClose }: SprintManagerProps) { export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
const items = vm.list?.items ?? []; const items = vm.list?.items ?? [];
const sprints = [...vm.sprints].sort((a, b) => a.order - b.order); const sprints = [...vm.sprints].sort((a, b) => a.order - b.order);
@ -39,6 +35,11 @@ export function SprintManager({ vm, onClose }: SprintManagerProps) {
const [confirmDelete, setConfirmDelete] = useState< const [confirmDelete, setConfirmDelete] = useState<
{ id: string; name: string } | null { id: string; name: string } | null
>(null); >(null);
// The sprint whose "add ticket" TicketPicker is open, or null (#19). Held as
// {id,name} so the picker keeps rendering while the underlying list refreshes.
const [pickerSprint, setPickerSprint] = useState<
{ id: string; name: string } | null
>(null);
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@ -208,28 +209,23 @@ export function SprintManager({ vm, onClose }: SprintManagerProps) {
</ul> </ul>
)} )}
{/* Add an existing ticket (backlog or another sprint). */} {/* Add an existing ticket via the TicketPicker popup (#19):
<select single-select, excluding tickets already in this sprint. */}
<div className="mt-1">
<Button
size="sm"
variant="ghost"
aria-label={`add ticket to sprint ${sprint.name}`} aria-label={`add ticket to sprint ${sprint.name}`}
className={cn(selectClass, "mt-1 max-w-xs")}
value=""
disabled={vm.busy || addable.length === 0} disabled={vm.busy || addable.length === 0}
onChange={(e) => { onClick={() =>
const ref = e.target.value; setPickerSprint({ id: sprint.id, name: sprint.name })
if (ref) void vm.assignSprint(ref, sprint.id); }
}}
> >
<option value="">
{addable.length === 0 {addable.length === 0
? "No tickets to add" ? "No tickets to add"
: "Add a ticket"} : "Ajouter un ticket"}
</option> </Button>
{addable.map((t) => ( </div>
<option key={t.ref} value={t.ref}>
{t.ref} {t.title}
</option>
))}
</select>
</div> </div>
</li> </li>
); );
@ -281,6 +277,29 @@ export function SprintManager({ vm, onClose }: SprintManagerProps) {
</div> </div>
</div> </div>
)} )}
{/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above
this sprint dialog. Excludes tickets already in the target sprint. ── */}
<TicketPicker
open={pickerSprint !== null}
projectId={projectId}
title={
pickerSprint
? `Ajouter un ticket au sprint « ${pickerSprint.name} »`
: "Ajouter un ticket"
}
selectionMode="single"
excludeRefs={items
.filter((t) => t.sprintId === pickerSprint?.id)
.map((t) => t.ref)}
onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result;
if (picked && pickerSprint) {
void vm.assignSprint(picked.ref, pickerSprint.id);
}
}}
onClose={() => setPickerSprint(null)}
/>
</div> </div>
); );
} }

View File

@ -241,7 +241,11 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
)} )}
{showSprints && ( {showSprints && (
<SprintManager vm={vm} onClose={() => setShowSprints(false)} /> <SprintManager
projectId={projectId}
vm={vm}
onClose={() => setShowSprints(false)}
/>
)} )}
</Panel> </Panel>
); );

View File

@ -805,23 +805,46 @@ describe("SprintManager (#11)", () => {
expect(fresh.title).toBe("Kept"); expect(fresh.title).toBe("Kept");
}); });
it("adds a backlog ticket to a sprint via the add selector", async () => { it("adds a backlog ticket to a sprint via the TicketPicker popup (#19)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" }); ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const t = await ticket.create(PROJECT_ID, { title: "Backlog item" }); const t = await ticket.create(PROJECT_ID, { title: "Backlog item" });
const dialog = await openManager(); const dialog = await openManager();
fireEvent.change( // Open the add-ticket picker for the Target sprint.
fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"), await within(dialog).findByLabelText("add ticket to sprint Target"),
{ target: { value: t.ref } },
); );
// The picker popup lists the backlog ticket — select it.
fireEvent.click(await screen.findByLabelText(`select ticket ${t.ref}`));
await waitFor(async () => { await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref); const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.sprintId).toBe("s1"); expect(fresh.sprintId).toBe("s1");
}); });
}); });
it("excludes tickets already in the sprint from the add picker (#19)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const member = await ticket.create(PROJECT_ID, { title: "Already in" });
await ticket.setTicketSprint(PROJECT_ID, member.ref, "s1", member.version);
const backlog = await ticket.create(PROJECT_ID, { title: "Still backlog" });
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"),
);
// The backlog ticket is offered; the one already in the sprint is excluded.
expect(
await screen.findByLabelText(`select ticket ${backlog.ref}`),
).toBeTruthy();
expect(
screen.queryByLabelText(`select ticket ${member.ref}`),
).toBeNull();
});
it("removes a ticket from a sprint", async () => { it("removes a ticket from a sprint", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" }); ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" });
const t = await ticket.create(PROJECT_ID, { title: "Member" }); const t = await ticket.create(PROJECT_ID, { title: "Member" });