From bd4f76ae7402ee66ab5950243c9837cbaf599305 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 20:38:07 +0200 Subject: [PATCH] bulk operations fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend: implémente bulkDelete, bulkArchive, bulkRestore - Backend: adapts ticket DTOs pour bulk operations - Infrastructure: MCP server pour gestion bulk tickets - Tests: coverage frontend + backend --- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/openai_tools.rs | 4 +- crates/app-tauri/src/state.rs | 1 + crates/app-tauri/src/tickets.rs | 32 ++++ crates/backend/src/lib.rs | 52 ++++--- crates/backend/src/openai_tools.rs | 4 +- crates/backend/src/ticket_dto.rs | 44 +++++- .../src/orchestrator/mcp/server.rs | 4 +- .../src/orchestrator/mcp/tickets.rs | 13 ++ .../src/orchestrator/mcp/tools.rs | 1 + crates/infrastructure/tests/mcp_server.rs | 15 +- frontend/src/adapters/http/streamGateways.ts | 16 ++ frontend/src/adapters/mock/index.ts | 70 +++++++++ frontend/src/adapters/ticket.ts | 27 ++++ frontend/src/domain/index.ts | 13 ++ .../src/features/tickets/TicketsPanel.tsx | 142 +++++++++++++++++- .../src/features/tickets/tickets.test.tsx | 88 +++++++++++ frontend/src/features/tickets/useTickets.ts | 90 +++++++++++ frontend/src/ports/index.ts | 15 ++ 19 files changed, 597 insertions(+), 35 deletions(-) diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 0013ecd..abb4fb9 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -280,6 +280,7 @@ pub fn run() { tickets::ticket_delete, tickets::ticket_bulk_update_status, tickets::ticket_bulk_update_priority, + tickets::ticket_bulk_delete, tickets::open_ticket_chat, tickets::close_ticket_chat, tickets::ticket_list, diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index da8545e..3bc76f0 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -344,7 +344,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { matches!( name, - "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + "idea_ticket_bulk_update_status" + | "idea_ticket_bulk_update_priority" + | "idea_ticket_bulk_delete" ) } diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 7bb6880..7357abb 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -68,6 +68,7 @@ impl AppState { update: Arc::clone(&core.update_issue), bulk_update_status: Arc::clone(&core.bulk_update_issue_status), bulk_update_priority: Arc::clone(&core.bulk_update_issue_priority), + bulk_delete: Arc::clone(&core.bulk_delete_issues), read_carnet: Arc::clone(&core.read_issue_carnet), update_carnet: Arc::clone(&core.update_issue_carnet), link: Arc::clone(&core.link_issues), diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index 11a9d6d..f944405 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -31,6 +31,7 @@ pub struct AppTicketToolProvider { pub update: Arc, pub bulk_update_status: Arc, pub bulk_update_priority: Arc, + pub bulk_delete: Arc, pub read_carnet: Arc, pub update_carnet: Arc, pub link: Arc, @@ -204,6 +205,14 @@ impl TicketToolProvider for AppTicketToolProvider { .await; json!(TicketBulkResultDto::from(out)) } + "idea_ticket_bulk_delete" => { + let req = mcp_bulk_delete_request(project, arguments)?; + let out = self + .bulk_delete + .execute(bulk_delete_input(project.clone(), req).map_err(dto_tool_error)?) + .await; + json!(TicketBulkResultDto::from(out)) + } "idea_ticket_read_carnet" => { let carnet = self .read_carnet @@ -390,6 +399,19 @@ pub async fn ticket_bulk_update_priority( Ok(TicketBulkResultDto::from(out)) } +#[tauri::command] +pub async fn ticket_bulk_delete( + request: TicketBulkDeleteRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let out = state + .bulk_delete_issues + .execute(bulk_delete_input(project, request)?) + .await; + Ok(TicketBulkResultDto::from(out)) +} + #[tauri::command] pub async fn open_ticket_chat( request: OpenTicketChatRequestDto, @@ -844,6 +866,16 @@ fn mcp_bulk_update_priority_request( Ok(req) } +fn mcp_bulk_delete_request( + project: &Project, + arguments: Value, +) -> Result { + let mut req: TicketBulkDeleteRequestDto = serde_json::from_value(arguments) + .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; + req.project_id = project.id.to_string(); + Ok(req) +} + fn parse_json_ref(arguments: &Value, key: &str) -> Result { required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error)) } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index f765917..0e9e7c0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -14,26 +14,26 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, - BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, - CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, - CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, - CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, - DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, - DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, - GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, - GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, - GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, - HealthUseCase, InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, - JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, - ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles, - ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, - ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, - ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, + ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, + CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, + ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, + CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, + DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, + GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, + InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, + LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, + ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, @@ -991,6 +991,8 @@ pub struct BackendCore { pub bulk_update_issue_status: Arc, /// Update public ticket priorities in batch. pub bulk_update_issue_priority: Arc, + /// Delete public tickets in batch. + pub bulk_delete_issues: Arc, /// Read a ticket carnet. pub read_issue_carnet: Arc, /// Update a ticket carnet. @@ -1684,6 +1686,10 @@ impl BackendCore { Arc::clone(&clock) as Arc, Arc::clone(&events_port), )); + let bulk_delete_issues = Arc::new(BulkDeleteIssues::new( + Arc::clone(&issue_store_port), + Arc::clone(&events_port), + )); let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port))); let update_issue_carnet = Arc::new(UpdateIssueCarnet::new( Arc::clone(&issue_store_port), @@ -2834,6 +2840,7 @@ impl BackendCore { update_issue, bulk_update_issue_status, bulk_update_issue_priority, + bulk_delete_issues, read_issue_carnet, update_issue_carnet, link_issues, @@ -4967,6 +4974,7 @@ mod mcp_serve_peer_tests { "idea_ticket_update_priority", "idea_ticket_bulk_update_status", "idea_ticket_bulk_update_priority", + "idea_ticket_bulk_delete", "idea_ticket_read_carnet", "idea_ticket_update_carnet", "idea_ticket_link", @@ -4987,8 +4995,8 @@ mod mcp_serve_peer_tests { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 33, - "exactly the thirty-three exposed idea_* tools; got {names:?}" + 34, + "exactly the thirty-four exposed idea_* tools; got {names:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 1a40edc..7391453 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -357,7 +357,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { matches!( name, - "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + "idea_ticket_bulk_update_status" + | "idea_ticket_bulk_update_priority" + | "idea_ticket_bulk_delete" ) } diff --git a/crates/backend/src/ticket_dto.rs b/crates/backend/src/ticket_dto.rs index 2ca6fe4..b003a89 100644 --- a/crates/backend/src/ticket_dto.rs +++ b/crates/backend/src/ticket_dto.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use application::{ - BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput, - CreateIssueInput, UpdateIssueInput, + BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, + BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput, }; use domain::{ AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, @@ -225,6 +225,15 @@ pub struct TicketBulkUpdatePriorityRequestDto { pub priority: String, } +/// Bulk delete request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketBulkDeleteRequestDto { + #[serde(default)] + pub project_id: String, + pub refs: Vec, +} + /// Bulk operation response. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -757,6 +766,16 @@ pub fn bulk_update_priority_input( }) } +pub fn bulk_delete_input( + project: Project, + request: TicketBulkDeleteRequestDto, +) -> Result { + Ok(BulkDeleteIssuesInput { + project, + issue_refs: parse_bulk_refs(request.refs)?, + }) +} + impl From for TicketBulkResultDto { fn from(output: BulkIssueMutationOutput) -> Self { Self { @@ -1272,6 +1291,27 @@ mod tests { assert!(err.message.contains("duplicate ticket ref: #1")); } + #[test] + fn bulk_delete_input_maps_refs_in_order() { + let input = bulk_delete_input( + project_for_tests(), + TicketBulkDeleteRequestDto { + project_id: String::new(), + refs: vec!["#2".into(), "#1".into()], + }, + ) + .unwrap(); + + assert_eq!( + input + .issue_refs + .iter() + .map(ToString::to_string) + .collect::>(), + vec!["#2", "#1"] + ); + } + #[test] fn actor_from_requester_maps_agent_uuid_else_user() { let agent_id = AgentId::new_random(); diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 7f49388..abf210a 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -919,7 +919,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { matches!( name, - "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + "idea_ticket_bulk_update_status" + | "idea_ticket_bulk_update_priority" + | "idea_ticket_bulk_delete" ) } diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index 3258b75..4142338 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -62,6 +62,7 @@ pub fn is_ticket_tool(name: &str) -> bool { | "idea_ticket_update_priority" | "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + | "idea_ticket_bulk_delete" | "idea_ticket_read_carnet" | "idea_ticket_update_carnet" | "idea_ticket_link" @@ -255,6 +256,18 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_ticket_bulk_delete", + description: "Delete several IdeA tickets and return one result per ticket.", + input_schema: json!({ + "type": "object", + "properties": { + "refs": refs.clone() + }, + "required": ["refs"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_ticket_read_carnet", description: "Read the editable carnet attached to a ticket.", diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index cb29f44..e0cecfe 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -76,6 +76,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_ticket_update_priority", "idea_ticket_bulk_update_status", "idea_ticket_bulk_update_priority", + "idea_ticket_bulk_delete", "idea_ticket_update_carnet", "idea_ticket_link", "idea_ticket_unlink", diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 28c56de..85242c8 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -751,6 +751,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { "idea_ticket_update_priority", "idea_ticket_bulk_update_status", "idea_ticket_bulk_update_priority", + "idea_ticket_bulk_delete", "idea_ticket_read_carnet", "idea_ticket_update_carnet", "idea_ticket_link", @@ -771,8 +772,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 33, - "exactly the thirty-three exposed idea_* tools; got {names:?}" + 34, + "exactly the thirty-four exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. @@ -1371,13 +1372,14 @@ async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider "idea_ticket_bulk_update_priority", json!({ "refs": ["#8"], "priority": "high" }), ), + (25, "idea_ticket_bulk_delete", json!({ "refs": ["#8"] })), ( - 25, + 26, "idea_ticket_link", json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), ( - 26, + 27, "idea_ticket_unlink", json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), @@ -1442,13 +1444,14 @@ async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provide "idea_ticket_bulk_update_priority", json!({ "refs": ["#7"], "priority": "high" }), ), + (35, "idea_ticket_bulk_delete", json!({ "refs": ["#7"] })), ( - 35, + 36, "idea_ticket_link", json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), ( - 36, + 37, "idea_ticket_unlink", json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 7744def..ea40883 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -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 { await this.http.invoke("ticket_delete", { request: { projectId, ref } }); } + bulkUpdateStatus(projectId: string, refs: string[], status: Ticket["status"]): Promise { + return this.http.invoke("ticket_bulk_update_status", { + request: { projectId, refs, status }, + }); + } + bulkUpdatePriority(projectId: string, refs: string[], priority: Ticket["priority"]): Promise { + return this.http.invoke("ticket_bulk_update_priority", { + request: { projectId, refs, priority }, + }); + } + bulkDelete(projectId: string, refs: string[]): Promise { + return this.http.invoke("ticket_bulk_delete", { + request: { projectId, refs }, + }); + } readCarnet(projectId: string, ref: string): Promise { return this.http.invoke("ticket_read_carnet", { request: { projectId, ref } }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 18b16da..6702c38 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { + 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 { + 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 { + 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 { const ticket = this.require(projectId, ref); return { diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index 625ab01..324a80d 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -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 { + return invoke("ticket_bulk_update_status", { + request: { projectId, refs, status }, + }); + } + + async bulkUpdatePriority( + projectId: string, + refs: string[], + priority: Ticket["priority"], + ): Promise { + return invoke("ticket_bulk_update_priority", { + request: { projectId, refs, priority }, + }); + } + + async bulkDelete(projectId: string, refs: string[]): Promise { + return invoke("ticket_bulk_delete", { + request: { projectId, refs }, + }); + } + async readCarnet(projectId: string, ref: string): Promise { return invoke("ticket_read_carnet", { request: { projectId, ref }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 70df616..6ad9675 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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; diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index cca7727..2cfcb0e 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -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(null); const [showSprintPicker, setShowSprintPicker] = useState(false); + const [selectedRefs, setSelectedRefs] = useState>(() => new Set()); + const [bulkStatus, setBulkStatus] = useState(""); + 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 ( )} + {selectedVisibleRefs.length > 0 && ( +
+ + {selectedVisibleRefs.length} sélectionné + {selectedVisibleRefs.length > 1 ? "s" : ""} + + ({ + value: status, + label: statusLabel(status), + })), + ]} + onChange={(next) => void applyBulkStatus(next as TicketStatus | "")} + /> + + +
+ )} + {showCreate && (
))} {noSprint.length > 0 && ( @@ -254,6 +341,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { onOpen={onOpen} nameOf={nameOf} onAssignSprint={vm.assignSprint} + selectedRefs={selectedRefs} + onToggleSelected={toggleTicketSelection} /> )} @@ -295,6 +384,44 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { onSelect={(sprint) => setNewSprint(sprint)} onClose={() => setShowSprintPicker(false)} /> + + {confirmBulkDelete && ( +
+
+

+ Supprimer {selectedVisibleRefs.length} ticket + {selectedVisibleRefs.length > 1 ? "s" : ""} ? +

+

+ Cette action supprimera les tickets sélectionnés du projet. +

+
+ + +
+
+
+ )} ); } @@ -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; + onToggleSelected: (ref: string, selected: boolean) => void; }) { return (
@@ -333,6 +464,13 @@ function SprintSection({ {tickets.map((t) => (
  • + 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" + />