feat(tickets): assignation de sprint en masse via multi-sélection
Permet de sélectionner plusieurs tickets dans TicketsPanel et de leur assigner un sprint en une seule action, au lieu d'un ticket à la fois. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -74,6 +74,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
const [showSprintPicker, setShowSprintPicker] = useState(false);
|
const [showSprintPicker, setShowSprintPicker] = useState(false);
|
||||||
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
|
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
|
||||||
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
|
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
|
||||||
|
const [showBulkSprintPicker, setShowBulkSprintPicker] = useState(false);
|
||||||
|
const [bulkNotice, setBulkNotice] = useState<string | null>(null);
|
||||||
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
||||||
|
|
||||||
const items = vm.list?.items ?? [];
|
const items = vm.list?.items ?? [];
|
||||||
@ -126,6 +128,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleTicketSelection(ref: string, selected: boolean) {
|
function toggleTicketSelection(ref: string, selected: boolean) {
|
||||||
|
setBulkNotice(null);
|
||||||
setSelectedRefs((prev) => {
|
setSelectedRefs((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (selected) next.add(ref);
|
if (selected) next.add(ref);
|
||||||
@ -135,6 +138,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyBulkStatus(nextStatus: TicketStatus | "") {
|
async function applyBulkStatus(nextStatus: TicketStatus | "") {
|
||||||
|
setBulkNotice(null);
|
||||||
setBulkStatus(nextStatus);
|
setBulkStatus(nextStatus);
|
||||||
if (!nextStatus || selectedVisibleRefs.length === 0) return;
|
if (!nextStatus || selectedVisibleRefs.length === 0) return;
|
||||||
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
|
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
|
||||||
@ -144,7 +148,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
setBulkStatus("");
|
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() {
|
async function applyBulkDelete() {
|
||||||
|
setBulkNotice(null);
|
||||||
const result = await vm.bulkDelete(selectedVisibleRefs);
|
const result = await vm.bulkDelete(selectedVisibleRefs);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
|
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 | "")}
|
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setShowBulkSprintPicker(true)}
|
||||||
|
>
|
||||||
|
Sprint
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@ -233,6 +264,15 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
</div>
|
</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 && (
|
{showCreate && (
|
||||||
<form
|
<form
|
||||||
onSubmit={submitCreate}
|
onSubmit={submitCreate}
|
||||||
@ -419,6 +459,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
onClose={() => setShowSprintPicker(false)}
|
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 && (
|
{confirmBulkDelete && (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
@ -886,6 +886,76 @@ describe("TicketsView", () => {
|
|||||||
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
|
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 () => {
|
it("selects several rows and deletes them after bulk confirmation", async () => {
|
||||||
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
|
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
|
||||||
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });
|
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });
|
||||||
|
|||||||
@ -60,6 +60,14 @@ export interface TicketsViewModel {
|
|||||||
priority: TicketPriority,
|
priority: TicketPriority,
|
||||||
) => Promise<TicketBulkResult | null>;
|
) => Promise<TicketBulkResult | null>;
|
||||||
bulkDelete: (refs: string[]) => 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
|
* 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
|
* 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],
|
[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(
|
const assignSprint = useCallback(
|
||||||
async (ref: string, sprintId: string | null): Promise<boolean> => {
|
async (ref: string, sprintId: string | null): Promise<boolean> => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -403,6 +457,7 @@ export function useTickets(projectId: string): TicketsViewModel {
|
|||||||
bulkUpdateStatus,
|
bulkUpdateStatus,
|
||||||
bulkUpdatePriority,
|
bulkUpdatePriority,
|
||||||
bulkDelete,
|
bulkDelete,
|
||||||
|
bulkAssignSprint,
|
||||||
assignSprint,
|
assignSprint,
|
||||||
createSprint,
|
createSprint,
|
||||||
renameSprint,
|
renameSprint,
|
||||||
|
|||||||
Reference in New Issue
Block a user