bulk operations fixes
- Frontend: implémente bulkDelete, bulkArchive, bulkRestore - Backend: adapts ticket DTOs pour bulk operations - Infrastructure: MCP server pour gestion bulk tickets - Tests: coverage frontend + backend
This commit is contained in:
@ -7,9 +7,9 @@
|
||||
* through {@link useTickets}.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
|
||||
import type { Sprint, TicketPriority, TicketStatus, TicketSummary } from "@/domain";
|
||||
import { Button, Input, Panel, Spinner } from "@/shared";
|
||||
import { useTickets } from "./useTickets";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
@ -21,8 +21,10 @@ import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
TICKET_PRIORITIES,
|
||||
TICKET_STATUSES,
|
||||
TicketRef,
|
||||
priorityLabel,
|
||||
statusLabel,
|
||||
} from "./ticketMeta";
|
||||
|
||||
export interface TicketsPanelProps {
|
||||
@ -56,8 +58,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
// picker popup is open (#38).
|
||||
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
|
||||
const [showSprintPicker, setShowSprintPicker] = useState(false);
|
||||
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
|
||||
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
||||
|
||||
const items = vm.list?.items ?? [];
|
||||
const visibleRefs = useMemo(() => new Set(items.map((t) => t.ref)), [items]);
|
||||
const selectedVisibleRefs = useMemo(
|
||||
() => [...selectedRefs].filter((ref) => visibleRefs.has(ref)),
|
||||
[selectedRefs, visibleRefs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRefs((prev) => {
|
||||
const next = new Set([...prev].filter((ref) => visibleRefs.has(ref)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [visibleRefs]);
|
||||
|
||||
// Group tickets by sprint (ticket #10): one ordered section per sprint that
|
||||
// holds tickets, then a "Sans sprint" bucket. Tickets referencing an unknown
|
||||
@ -94,6 +111,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
onOpen(created.ref);
|
||||
}
|
||||
|
||||
function toggleTicketSelection(ref: string, selected: boolean) {
|
||||
setSelectedRefs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) next.add(ref);
|
||||
else next.delete(ref);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function applyBulkStatus(nextStatus: TicketStatus | "") {
|
||||
setBulkStatus(nextStatus);
|
||||
if (!nextStatus || selectedVisibleRefs.length === 0) return;
|
||||
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
|
||||
if (!result) return;
|
||||
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
|
||||
setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref))));
|
||||
setBulkStatus("");
|
||||
}
|
||||
|
||||
async function applyBulkDelete() {
|
||||
const result = await vm.bulkDelete(selectedVisibleRefs);
|
||||
if (!result) return;
|
||||
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
|
||||
setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref))));
|
||||
setConfirmBulkDelete(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Tickets"
|
||||
@ -134,6 +178,47 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedVisibleRefs.length > 0 && (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="bulk ticket actions"
|
||||
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-raised/70 px-2 py-2"
|
||||
>
|
||||
<span className="text-xs font-medium text-content">
|
||||
{selectedVisibleRefs.length} sélectionné
|
||||
{selectedVisibleRefs.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
<TicketViewportSelect
|
||||
aria-label="Changer le statut"
|
||||
value={bulkStatus}
|
||||
options={[
|
||||
{ value: "", label: "Changer le statut" },
|
||||
...TICKET_STATUSES.map((status) => ({
|
||||
value: status,
|
||||
label: statusLabel(status),
|
||||
})),
|
||||
]}
|
||||
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setConfirmBulkDelete(true)}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setSelectedRefs(new Set())}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<form
|
||||
onSubmit={submitCreate}
|
||||
@ -243,6 +328,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
onOpen={onOpen}
|
||||
nameOf={nameOf}
|
||||
onAssignSprint={vm.assignSprint}
|
||||
selectedRefs={selectedRefs}
|
||||
onToggleSelected={toggleTicketSelection}
|
||||
/>
|
||||
))}
|
||||
{noSprint.length > 0 && (
|
||||
@ -254,6 +341,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
onOpen={onOpen}
|
||||
nameOf={nameOf}
|
||||
onAssignSprint={vm.assignSprint}
|
||||
selectedRefs={selectedRefs}
|
||||
onToggleSelected={toggleTicketSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -295,6 +384,44 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
onSelect={(sprint) => setNewSprint(sprint)}
|
||||
onClose={() => setShowSprintPicker(false)}
|
||||
/>
|
||||
|
||||
{confirmBulkDelete && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="confirmer la suppression groupée"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-panel p-4 shadow-lg">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
Supprimer {selectedVisibleRefs.length} ticket
|
||||
{selectedVisibleRefs.length > 1 ? "s" : ""} ?
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Cette action supprimera les tickets sélectionnés du projet.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel bulk delete"
|
||||
onClick={() => setConfirmBulkDelete(false)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
aria-label="confirm bulk delete"
|
||||
loading={vm.busy}
|
||||
onClick={() => void applyBulkDelete()}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -312,6 +439,8 @@ function SprintSection({
|
||||
onOpen,
|
||||
nameOf,
|
||||
onAssignSprint,
|
||||
selectedRefs,
|
||||
onToggleSelected,
|
||||
}: {
|
||||
heading: string;
|
||||
count: number;
|
||||
@ -320,6 +449,8 @@ function SprintSection({
|
||||
onOpen: (ref: string) => void;
|
||||
nameOf: (id: string) => string;
|
||||
onAssignSprint: (ref: string, sprintId: string | null) => void;
|
||||
selectedRefs: Set<string>;
|
||||
onToggleSelected: (ref: string, selected: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<section aria-label={`sprint section ${heading}`}>
|
||||
@ -333,6 +464,13 @@ function SprintSection({
|
||||
{tickets.map((t) => (
|
||||
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`select ticket row ${t.ref}`}
|
||||
checked={selectedRefs.has(t.ref)}
|
||||
onChange={(e) => onToggleSelected(t.ref, e.currentTarget.checked)}
|
||||
className="mt-1 h-4 w-4 shrink-0 rounded border-border bg-panel text-primary focus-visible:outline-none"
|
||||
/>
|
||||
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user