merge feature/ticket112-bulk-sprint-assign dans develop (#112: assignation de sprint en masse via multi-sélection)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:58:12 +02:00
3 changed files with 176 additions and 0 deletions

View File

@ -74,6 +74,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const [showSprintPicker, setShowSprintPicker] = useState(false);
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
const [showBulkSprintPicker, setShowBulkSprintPicker] = useState(false);
const [bulkNotice, setBulkNotice] = useState<string | null>(null);
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
const items = vm.list?.items ?? [];
@ -126,6 +128,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}
function toggleTicketSelection(ref: string, selected: boolean) {
setBulkNotice(null);
setSelectedRefs((prev) => {
const next = new Set(prev);
if (selected) next.add(ref);
@ -135,6 +138,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}
async function applyBulkStatus(nextStatus: TicketStatus | "") {
setBulkNotice(null);
setBulkStatus(nextStatus);
if (!nextStatus || selectedVisibleRefs.length === 0) return;
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
@ -144,7 +148,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
setBulkStatus("");
}
async function applyBulkSprint(sprint: Sprint | null) {
setBulkNotice(null);
const refs = selectedVisibleRefs;
if (refs.length === 0) return;
const result = await vm.bulkAssignSprint(refs, sprint?.id ?? null);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
const successCount = result.items.length - failed.size;
setSelectedRefs(new Set(refs.filter((ref) => failed.has(ref))));
setBulkNotice(
failed.size === 0
? `Sprint mis à jour pour ${successCount} ticket${
successCount > 1 ? "s" : ""
}.`
: `Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`,
);
}
async function applyBulkDelete() {
setBulkNotice(null);
const result = await vm.bulkDelete(selectedVisibleRefs);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
@ -214,6 +237,14 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
/>
<Button
size="sm"
variant="secondary"
disabled={vm.busy}
onClick={() => setShowBulkSprintPicker(true)}
>
Sprint
</Button>
<Button
size="sm"
variant="danger"
@ -233,6 +264,15 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
</div>
)}
{bulkNotice && (
<p
role="status"
className="mb-3 rounded-md border border-border bg-raised/60 px-3 py-2 text-sm text-content"
>
{bulkNotice}
</p>
)}
{showCreate && (
<form
onSubmit={submitCreate}
@ -419,6 +459,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onClose={() => setShowSprintPicker(false)}
/>
<SprintPicker
open={showBulkSprintPicker}
projectId={projectId}
selectedSprintId={null}
title={`Sprint pour ${selectedVisibleRefs.length} ticket${
selectedVisibleRefs.length > 1 ? "s" : ""
}`}
onSelect={(sprint) => void applyBulkSprint(sprint)}
onClose={() => setShowBulkSprintPicker(false)}
/>
{confirmBulkDelete && (
<div
role="dialog"

View File

@ -886,6 +886,76 @@ describe("TicketsView", () => {
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
});
it("selects several rows and assigns their sprint from the bulk action bar (#112)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const a = await ticket.create(PROJECT_ID, { title: "Sprint Bulk A" });
const b = await ticket.create(PROJECT_ID, { title: "Sprint Bulk B" });
await ticket.create(PROJECT_ID, { title: "Unselected sprint item" });
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
await waitFor(() => {
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, a.ref, "s1", a.version);
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, b.ref, "s1", b.version);
});
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBe("s1");
});
expect(screen.getByRole("status").textContent).toMatch(
/Sprint mis à jour pour 2 tickets/i,
);
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
});
it("keeps failed refs selected after a partial bulk sprint assignment (#112)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const a = await ticket.create(PROJECT_ID, { title: "Sprint Partial A" });
const b = await ticket.create(PROJECT_ID, { title: "Sprint Partial B" });
const originalSetTicketSprint = ticket.setTicketSprint.bind(ticket);
vi.spyOn(ticket, "setTicketSprint").mockImplementation(
async (projectId, ref, sprintId, expectedVersion) => {
if (ref === b.ref) {
throw { code: "CONFLICT", message: "version conflict" };
}
return originalSetTicketSprint(projectId, ref, sprintId, expectedVersion);
},
);
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBeNull();
});
expect((await screen.findByRole("alert")).textContent).toContain(
`1 ticket(s) non modifié(s): ${b.ref}`,
);
expect(screen.getByRole("status").textContent).toMatch(/1\/2 tickets/i);
expect(
(screen.getByLabelText(`select ticket row ${b.ref}`) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText(`select ticket row ${a.ref}`) as HTMLInputElement)
.checked,
).toBe(false);
});
it("selects several rows and deletes them after bulk confirmation", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });

View File

@ -60,6 +60,14 @@ export interface TicketsViewModel {
priority: TicketPriority,
) => Promise<TicketBulkResult | null>;
bulkDelete: (refs: string[]) => Promise<TicketBulkResult | null>;
/**
* Assigns several tickets to a sprint by reusing the single-ticket sprint
* contract. No backend bulk command exists for this path by design (#112).
*/
bulkAssignSprint: (
refs: string[],
sprintId: string | null,
) => Promise<TicketBulkResult | null>;
/**
* Assigns a ticket to a sprint (or clears it with `sprintId === null`). Reads
* the ticket's current version first so the summary-based list needs no
@ -265,6 +273,52 @@ export function useTickets(projectId: string): TicketsViewModel {
[projectId, ticket, refresh, refreshSprints],
);
const bulkAssignSprint = useCallback(
async (
refs: string[],
sprintId: string | null,
): Promise<TicketBulkResult | null> => {
if (refs.length === 0) return { items: [] };
setBusy(true);
setError(null);
try {
const items: TicketBulkResult["items"] = [];
for (const ref of refs) {
try {
const current = await ticket.read(projectId, ref);
const updated = await ticket.setTicketSprint(
projectId,
ref,
sprintId,
current.version,
);
items.push({ ref, ok: true, ticket: updated });
} catch (e) {
items.push({
ref,
ok: false,
error:
e && typeof e === "object" && "message" in e
? (e as GatewayError)
: { code: "UNKNOWN", message: String(e) },
});
}
}
const result = { items };
const failure = describeBulkFailures(result);
await Promise.all([refresh(), refreshSprints()]);
if (failure) setError(failure);
return result;
} catch (e) {
setError(describe(e));
return null;
} finally {
setBusy(false);
}
},
[projectId, ticket, refresh, refreshSprints],
);
const assignSprint = useCallback(
async (ref: string, sprintId: string | null): Promise<boolean> => {
setError(null);
@ -403,6 +457,7 @@ export function useTickets(projectId: string): TicketsViewModel {
bulkUpdateStatus,
bulkUpdatePriority,
bulkDelete,
bulkAssignSprint,
assignSprint,
createSprint,
renameSprint,