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:
@ -29,6 +29,7 @@ import type {
|
||||
Sprint,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
TicketLinkKind,
|
||||
@ -297,6 +298,21 @@ export class HttpTicketGateway implements TicketGateway {
|
||||
async delete(projectId: string, ref: string): Promise<void> {
|
||||
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
|
||||
}
|
||||
bulkUpdateStatus(projectId: string, refs: string[], status: Ticket["status"]): Promise<TicketBulkResult> {
|
||||
return this.http.invoke<TicketBulkResult>("ticket_bulk_update_status", {
|
||||
request: { projectId, refs, status },
|
||||
});
|
||||
}
|
||||
bulkUpdatePriority(projectId: string, refs: string[], priority: Ticket["priority"]): Promise<TicketBulkResult> {
|
||||
return this.http.invoke<TicketBulkResult>("ticket_bulk_update_priority", {
|
||||
request: { projectId, refs, priority },
|
||||
});
|
||||
}
|
||||
bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
|
||||
return this.http.invoke<TicketBulkResult>("ticket_bulk_delete", {
|
||||
request: { projectId, refs },
|
||||
});
|
||||
}
|
||||
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
||||
}
|
||||
|
||||
@ -66,6 +66,7 @@ import type {
|
||||
SystemPermissionSet,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
TicketLink,
|
||||
@ -2910,6 +2911,75 @@ export class MockTicketGateway implements TicketGateway {
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
projectId: string,
|
||||
refs: string[],
|
||||
status: Ticket["status"],
|
||||
): Promise<TicketBulkResult> {
|
||||
const items = refs.map((ref) => {
|
||||
try {
|
||||
const ticket = this.require(projectId, ref);
|
||||
ticket.status = status;
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueUpdated",
|
||||
issueRef: ref,
|
||||
version: ticket.version,
|
||||
});
|
||||
return { ref, ok: true, ticket: structuredClone(ticket) };
|
||||
} catch (error) {
|
||||
return { ref, ok: false, error: error as GatewayError };
|
||||
}
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
|
||||
async bulkUpdatePriority(
|
||||
projectId: string,
|
||||
refs: string[],
|
||||
priority: Ticket["priority"],
|
||||
): Promise<TicketBulkResult> {
|
||||
const items = refs.map((ref) => {
|
||||
try {
|
||||
const ticket = this.require(projectId, ref);
|
||||
ticket.priority = priority;
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueUpdated",
|
||||
issueRef: ref,
|
||||
version: ticket.version,
|
||||
});
|
||||
return { ref, ok: true, ticket: structuredClone(ticket) };
|
||||
} catch (error) {
|
||||
return { ref, ok: false, error: error as GatewayError };
|
||||
}
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
|
||||
async bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
|
||||
const items = refs.map((ref) => {
|
||||
try {
|
||||
const ticket = this.require(projectId, ref);
|
||||
const deleted = structuredClone(ticket);
|
||||
const freedSprint = ticket.sprintId ?? null;
|
||||
this.projectTickets(projectId).delete(ref);
|
||||
this.projectCarnets(projectId).delete(ref);
|
||||
if (freedSprint) this.recountSprints(projectId);
|
||||
this.system?.emit({
|
||||
type: "issueDeleted",
|
||||
projectId,
|
||||
issueRef: ref,
|
||||
freedSprint,
|
||||
});
|
||||
return { ref, ok: true, ticket: deleted };
|
||||
} catch (error) {
|
||||
return { ref, ok: false, error: error as GatewayError };
|
||||
}
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
|
||||
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
return {
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ReplyChunk,
|
||||
Sprint,
|
||||
Ticket,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
TicketLinkKind,
|
||||
@ -90,6 +91,32 @@ export class TauriTicketGateway implements TicketGateway {
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
projectId: string,
|
||||
refs: string[],
|
||||
status: Ticket["status"],
|
||||
): Promise<TicketBulkResult> {
|
||||
return invoke<TicketBulkResult>("ticket_bulk_update_status", {
|
||||
request: { projectId, refs, status },
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdatePriority(
|
||||
projectId: string,
|
||||
refs: string[],
|
||||
priority: Ticket["priority"],
|
||||
): Promise<TicketBulkResult> {
|
||||
return invoke<TicketBulkResult>("ticket_bulk_update_priority", {
|
||||
request: { projectId, refs, priority },
|
||||
});
|
||||
}
|
||||
|
||||
async bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
|
||||
return invoke<TicketBulkResult>("ticket_bulk_delete", {
|
||||
request: { projectId, refs },
|
||||
});
|
||||
}
|
||||
|
||||
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||
return invoke<TicketCarnet>("ticket_read_carnet", {
|
||||
request: { projectId, ref },
|
||||
|
||||
@ -1538,6 +1538,19 @@ export interface TicketList {
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/** Per-ticket result for a bulk ticket mutation. */
|
||||
export interface TicketBulkResultItem {
|
||||
ref: TicketRef;
|
||||
ok: boolean;
|
||||
ticket?: Ticket;
|
||||
error?: GatewayError;
|
||||
}
|
||||
|
||||
/** Result of a bulk ticket mutation; item order mirrors the requested refs. */
|
||||
export interface TicketBulkResult {
|
||||
items: TicketBulkResultItem[];
|
||||
}
|
||||
|
||||
/** The scoped-to-a-ticket Markdown carnet (mirror of `TicketCarnetDto`). */
|
||||
export interface TicketCarnet {
|
||||
ref: TicketRef;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -178,6 +178,44 @@ describe("MockTicketGateway", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bulk updates status and reports partial failures in request order", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "A" });
|
||||
const b = await ticket.create(PROJECT_ID, { title: "B" });
|
||||
|
||||
const out = await ticket.bulkUpdateStatus(
|
||||
PROJECT_ID,
|
||||
[a.ref, "#404", b.ref],
|
||||
"QA",
|
||||
);
|
||||
|
||||
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
|
||||
[a.ref, true],
|
||||
["#404", false],
|
||||
[b.ref, true],
|
||||
]);
|
||||
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
|
||||
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
|
||||
});
|
||||
|
||||
it("bulk deletes existing tickets and keeps missing refs as item failures", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "A" });
|
||||
const b = await ticket.create(PROJECT_ID, { title: "B" });
|
||||
|
||||
const out = await ticket.bulkDelete(PROJECT_ID, [a.ref, "#404", b.ref]);
|
||||
|
||||
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
|
||||
[a.ref, true],
|
||||
["#404", false],
|
||||
[b.ref, true],
|
||||
]);
|
||||
await expect(ticket.read(PROJECT_ID, a.ref)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await expect(ticket.read(PROJECT_ID, b.ref)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("links and unlinks tickets, bumping the version", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
||||
await ticket.create(PROJECT_ID, { title: "b" });
|
||||
@ -692,6 +730,56 @@ describe("TicketsView", () => {
|
||||
expect(await screen.findByText("No tickets.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("selects several rows and changes their status from the bulk action bar", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "Bulk A" });
|
||||
const b = await ticket.create(PROJECT_ID, { title: "Bulk B" });
|
||||
await ticket.create(PROJECT_ID, { title: "Unselected" });
|
||||
const bulkSpy = vi.spyOn(ticket, "bulkUpdateStatus");
|
||||
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
|
||||
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
|
||||
|
||||
expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText("Changer le statut"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "QA" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref], "QA"),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
|
||||
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
|
||||
});
|
||||
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
|
||||
});
|
||||
|
||||
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" });
|
||||
await ticket.create(PROJECT_ID, { title: "Keep me" });
|
||||
const bulkSpy = vi.spyOn(ticket, "bulkDelete");
|
||||
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
|
||||
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
|
||||
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: "confirmer la suppression groupée",
|
||||
});
|
||||
fireEvent.click(within(confirm).getByLabelText("confirm bulk delete"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("Delete A")).toBeNull());
|
||||
expect(screen.queryByText("Delete B")).toBeNull();
|
||||
expect(screen.getByText("Keep me")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
|
||||
const delSpy = vi.spyOn(ticket, "delete");
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
type GatewayError,
|
||||
type Sprint,
|
||||
type Ticket,
|
||||
type TicketBulkResult,
|
||||
type TicketList,
|
||||
type TicketPriority,
|
||||
type TicketStatus,
|
||||
@ -50,6 +51,15 @@ export interface TicketsViewModel {
|
||||
clearFacets: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
||||
bulkUpdateStatus: (
|
||||
refs: string[],
|
||||
status: TicketStatus,
|
||||
) => Promise<TicketBulkResult | null>;
|
||||
bulkUpdatePriority: (
|
||||
refs: string[],
|
||||
priority: TicketPriority,
|
||||
) => Promise<TicketBulkResult | null>;
|
||||
bulkDelete: (refs: string[]) => 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
|
||||
@ -79,6 +89,14 @@ function describe(e: unknown): string {
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function describeBulkFailures(result: TicketBulkResult): string | null {
|
||||
const failed = result.items.filter((item) => !item.ok);
|
||||
if (failed.length === 0) return null;
|
||||
return `${failed.length} ticket(s) non modifié(s): ${failed
|
||||
.map((item) => item.ref)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
export function useTickets(projectId: string): TicketsViewModel {
|
||||
const { ticket, system, uiPreferences } = useGateways();
|
||||
const [list, setList] = useState<TicketList | null>(null);
|
||||
@ -178,6 +196,75 @@ export function useTickets(projectId: string): TicketsViewModel {
|
||||
[projectId, ticket, refresh],
|
||||
);
|
||||
|
||||
const bulkUpdateStatus = useCallback(
|
||||
async (
|
||||
refs: string[],
|
||||
status: TicketStatus,
|
||||
): Promise<TicketBulkResult | null> => {
|
||||
if (refs.length === 0) return { items: [] };
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await ticket.bulkUpdateStatus(projectId, refs, status);
|
||||
const failure = describeBulkFailures(result);
|
||||
if (failure) setError(failure);
|
||||
await refresh();
|
||||
return result;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[projectId, ticket, refresh],
|
||||
);
|
||||
|
||||
const bulkUpdatePriority = useCallback(
|
||||
async (
|
||||
refs: string[],
|
||||
priority: TicketPriority,
|
||||
): Promise<TicketBulkResult | null> => {
|
||||
if (refs.length === 0) return { items: [] };
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await ticket.bulkUpdatePriority(projectId, refs, priority);
|
||||
const failure = describeBulkFailures(result);
|
||||
if (failure) setError(failure);
|
||||
await refresh();
|
||||
return result;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[projectId, ticket, refresh],
|
||||
);
|
||||
|
||||
const bulkDelete = useCallback(
|
||||
async (refs: string[]): Promise<TicketBulkResult | null> => {
|
||||
if (refs.length === 0) return { items: [] };
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await ticket.bulkDelete(projectId, refs);
|
||||
const failure = describeBulkFailures(result);
|
||||
if (failure) setError(failure);
|
||||
await Promise.all([refresh(), refreshSprints()]);
|
||||
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);
|
||||
@ -313,6 +400,9 @@ export function useTickets(projectId: string): TicketsViewModel {
|
||||
clearFacets,
|
||||
refresh,
|
||||
create,
|
||||
bulkUpdateStatus,
|
||||
bulkUpdatePriority,
|
||||
bulkDelete,
|
||||
assignSprint,
|
||||
createSprint,
|
||||
renameSprint,
|
||||
|
||||
@ -67,6 +67,7 @@ import type {
|
||||
Template,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketBulkResult,
|
||||
TicketChat,
|
||||
TicketCarnet,
|
||||
TicketLinkKind,
|
||||
@ -1067,6 +1068,20 @@ export interface TicketGateway {
|
||||
* of an open detail view. Callers must not mutate local state imperatively.
|
||||
*/
|
||||
delete(projectId: string, ref: string): Promise<void>;
|
||||
/** Updates several tickets' status in one command; partial failures are per item. */
|
||||
bulkUpdateStatus(
|
||||
projectId: string,
|
||||
refs: string[],
|
||||
status: TicketStatus,
|
||||
): Promise<TicketBulkResult>;
|
||||
/** Updates several tickets' priority in one command; partial failures are per item. */
|
||||
bulkUpdatePriority(
|
||||
projectId: string,
|
||||
refs: string[],
|
||||
priority: TicketPriority,
|
||||
): Promise<TicketBulkResult>;
|
||||
/** Deletes several tickets in one command; partial failures are per item. */
|
||||
bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult>;
|
||||
/** Reads the ticket-scoped Markdown carnet. */
|
||||
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
|
||||
/** Replaces (not appends) the ticket-scoped carnet body. */
|
||||
|
||||
Reference in New Issue
Block a user