feat(tickets): sélection de ticket via popup dans la gestion de sprint (#19)
Lot D du sprint UI rework : branche la popup réutilisable TicketPicker (#18) dans la sélection de ticket de la création/édition de sprint. - SprintManager : sélection de ticket déléguée à la popup TicketPicker - TicketsPanel : ajustements liés à l'intégration Tests : tsc --noEmit clean, suite complète 528/528, suite tickets 38/38 (dont 2 nouveaux tests #19). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user