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:
@ -280,6 +280,7 @@ pub fn run() {
|
|||||||
tickets::ticket_delete,
|
tickets::ticket_delete,
|
||||||
tickets::ticket_bulk_update_status,
|
tickets::ticket_bulk_update_status,
|
||||||
tickets::ticket_bulk_update_priority,
|
tickets::ticket_bulk_update_priority,
|
||||||
|
tickets::ticket_bulk_delete,
|
||||||
tickets::open_ticket_chat,
|
tickets::open_ticket_chat,
|
||||||
tickets::close_ticket_chat,
|
tickets::close_ticket_chat,
|
||||||
tickets::ticket_list,
|
tickets::ticket_list,
|
||||||
|
|||||||
@ -344,7 +344,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
name,
|
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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,6 +68,7 @@ impl AppState {
|
|||||||
update: Arc::clone(&core.update_issue),
|
update: Arc::clone(&core.update_issue),
|
||||||
bulk_update_status: Arc::clone(&core.bulk_update_issue_status),
|
bulk_update_status: Arc::clone(&core.bulk_update_issue_status),
|
||||||
bulk_update_priority: Arc::clone(&core.bulk_update_issue_priority),
|
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),
|
read_carnet: Arc::clone(&core.read_issue_carnet),
|
||||||
update_carnet: Arc::clone(&core.update_issue_carnet),
|
update_carnet: Arc::clone(&core.update_issue_carnet),
|
||||||
link: Arc::clone(&core.link_issues),
|
link: Arc::clone(&core.link_issues),
|
||||||
|
|||||||
@ -31,6 +31,7 @@ pub struct AppTicketToolProvider {
|
|||||||
pub update: Arc<application::UpdateIssue>,
|
pub update: Arc<application::UpdateIssue>,
|
||||||
pub bulk_update_status: Arc<application::BulkUpdateIssueStatus>,
|
pub bulk_update_status: Arc<application::BulkUpdateIssueStatus>,
|
||||||
pub bulk_update_priority: Arc<application::BulkUpdateIssuePriority>,
|
pub bulk_update_priority: Arc<application::BulkUpdateIssuePriority>,
|
||||||
|
pub bulk_delete: Arc<application::BulkDeleteIssues>,
|
||||||
pub read_carnet: Arc<application::ReadIssueCarnet>,
|
pub read_carnet: Arc<application::ReadIssueCarnet>,
|
||||||
pub update_carnet: Arc<application::UpdateIssueCarnet>,
|
pub update_carnet: Arc<application::UpdateIssueCarnet>,
|
||||||
pub link: Arc<application::LinkIssues>,
|
pub link: Arc<application::LinkIssues>,
|
||||||
@ -204,6 +205,14 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
.await;
|
.await;
|
||||||
json!(TicketBulkResultDto::from(out))
|
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" => {
|
"idea_ticket_read_carnet" => {
|
||||||
let carnet = self
|
let carnet = self
|
||||||
.read_carnet
|
.read_carnet
|
||||||
@ -390,6 +399,19 @@ pub async fn ticket_bulk_update_priority(
|
|||||||
Ok(TicketBulkResultDto::from(out))
|
Ok(TicketBulkResultDto::from(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ticket_bulk_delete(
|
||||||
|
request: TicketBulkDeleteRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<TicketBulkResultDto, ErrorDto> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub async fn open_ticket_chat(
|
pub async fn open_ticket_chat(
|
||||||
request: OpenTicketChatRequestDto,
|
request: OpenTicketChatRequestDto,
|
||||||
@ -844,6 +866,16 @@ fn mcp_bulk_update_priority_request(
|
|||||||
Ok(req)
|
Ok(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mcp_bulk_delete_request(
|
||||||
|
project: &Project,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<TicketBulkDeleteRequestDto, TicketToolError> {
|
||||||
|
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<IssueRef, TicketToolError> {
|
fn parse_json_ref(arguments: &Value, key: &str) -> Result<IssueRef, TicketToolError> {
|
||||||
required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error))
|
required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,26 +14,26 @@ use std::sync::{Arc, Mutex};
|
|||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
||||||
BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile,
|
BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask,
|
||||||
CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject,
|
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed,
|
||||||
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
|
||||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
||||||
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile,
|
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
||||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||||
DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState,
|
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||||
GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||||
GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||||
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||||
HealthUseCase, InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
||||||
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||||
ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles,
|
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||||
ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||||
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
@ -991,6 +991,8 @@ pub struct BackendCore {
|
|||||||
pub bulk_update_issue_status: Arc<BulkUpdateIssueStatus>,
|
pub bulk_update_issue_status: Arc<BulkUpdateIssueStatus>,
|
||||||
/// Update public ticket priorities in batch.
|
/// Update public ticket priorities in batch.
|
||||||
pub bulk_update_issue_priority: Arc<BulkUpdateIssuePriority>,
|
pub bulk_update_issue_priority: Arc<BulkUpdateIssuePriority>,
|
||||||
|
/// Delete public tickets in batch.
|
||||||
|
pub bulk_delete_issues: Arc<BulkDeleteIssues>,
|
||||||
/// Read a ticket carnet.
|
/// Read a ticket carnet.
|
||||||
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
||||||
/// Update a ticket carnet.
|
/// Update a ticket carnet.
|
||||||
@ -1684,6 +1686,10 @@ impl BackendCore {
|
|||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
Arc::clone(&events_port),
|
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 read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port)));
|
||||||
let update_issue_carnet = Arc::new(UpdateIssueCarnet::new(
|
let update_issue_carnet = Arc::new(UpdateIssueCarnet::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
@ -2834,6 +2840,7 @@ impl BackendCore {
|
|||||||
update_issue,
|
update_issue,
|
||||||
bulk_update_issue_status,
|
bulk_update_issue_status,
|
||||||
bulk_update_issue_priority,
|
bulk_update_issue_priority,
|
||||||
|
bulk_delete_issues,
|
||||||
read_issue_carnet,
|
read_issue_carnet,
|
||||||
update_issue_carnet,
|
update_issue_carnet,
|
||||||
link_issues,
|
link_issues,
|
||||||
@ -4967,6 +4974,7 @@ mod mcp_serve_peer_tests {
|
|||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
"idea_ticket_bulk_update_status",
|
"idea_ticket_bulk_update_status",
|
||||||
"idea_ticket_bulk_update_priority",
|
"idea_ticket_bulk_update_priority",
|
||||||
|
"idea_ticket_bulk_delete",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
@ -4987,8 +4995,8 @@ mod mcp_serve_peer_tests {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
33,
|
34,
|
||||||
"exactly the thirty-three exposed idea_* tools; got {names:?}"
|
"exactly the thirty-four exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(client); // EOF ⇒ serve loop ends
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
|
|||||||
@ -357,7 +357,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
name,
|
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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput,
|
BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriorityInput,
|
||||||
CreateIssueInput, UpdateIssueInput,
|
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||||
@ -225,6 +225,15 @@ pub struct TicketBulkUpdatePriorityRequestDto {
|
|||||||
pub priority: String,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bulk operation response.
|
/// Bulk operation response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -757,6 +766,16 @@ pub fn bulk_update_priority_input(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn bulk_delete_input(
|
||||||
|
project: Project,
|
||||||
|
request: TicketBulkDeleteRequestDto,
|
||||||
|
) -> Result<BulkDeleteIssuesInput, ErrorDto> {
|
||||||
|
Ok(BulkDeleteIssuesInput {
|
||||||
|
project,
|
||||||
|
issue_refs: parse_bulk_refs(request.refs)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl From<BulkIssueMutationOutput> for TicketBulkResultDto {
|
impl From<BulkIssueMutationOutput> for TicketBulkResultDto {
|
||||||
fn from(output: BulkIssueMutationOutput) -> Self {
|
fn from(output: BulkIssueMutationOutput) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -1272,6 +1291,27 @@ mod tests {
|
|||||||
assert!(err.message.contains("duplicate ticket ref: #1"));
|
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<_>>(),
|
||||||
|
vec!["#2", "#1"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn actor_from_requester_maps_agent_uuid_else_user() {
|
fn actor_from_requester_maps_agent_uuid_else_user() {
|
||||||
let agent_id = AgentId::new_random();
|
let agent_id = AgentId::new_random();
|
||||||
|
|||||||
@ -919,7 +919,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
name,
|
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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@ pub fn is_ticket_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_update_priority"
|
| "idea_ticket_update_priority"
|
||||||
| "idea_ticket_bulk_update_status"
|
| "idea_ticket_bulk_update_status"
|
||||||
| "idea_ticket_bulk_update_priority"
|
| "idea_ticket_bulk_update_priority"
|
||||||
|
| "idea_ticket_bulk_delete"
|
||||||
| "idea_ticket_read_carnet"
|
| "idea_ticket_read_carnet"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
@ -255,6 +256,18 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"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 {
|
ToolDef {
|
||||||
name: "idea_ticket_read_carnet",
|
name: "idea_ticket_read_carnet",
|
||||||
description: "Read the editable carnet attached to a ticket.",
|
description: "Read the editable carnet attached to a ticket.",
|
||||||
|
|||||||
@ -76,6 +76,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
"idea_ticket_bulk_update_status",
|
"idea_ticket_bulk_update_status",
|
||||||
"idea_ticket_bulk_update_priority",
|
"idea_ticket_bulk_update_priority",
|
||||||
|
"idea_ticket_bulk_delete",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
|
|||||||
@ -751,6 +751,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
"idea_ticket_bulk_update_status",
|
"idea_ticket_bulk_update_status",
|
||||||
"idea_ticket_bulk_update_priority",
|
"idea_ticket_bulk_update_priority",
|
||||||
|
"idea_ticket_bulk_delete",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
@ -771,8 +772,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
33,
|
34,
|
||||||
"exactly the thirty-three exposed idea_* tools; got {names:?}"
|
"exactly the thirty-four exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// 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",
|
"idea_ticket_bulk_update_priority",
|
||||||
json!({ "refs": ["#8"], "priority": "high" }),
|
json!({ "refs": ["#8"], "priority": "high" }),
|
||||||
),
|
),
|
||||||
|
(25, "idea_ticket_bulk_delete", json!({ "refs": ["#8"] })),
|
||||||
(
|
(
|
||||||
25,
|
26,
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
26,
|
27,
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
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",
|
"idea_ticket_bulk_update_priority",
|
||||||
json!({ "refs": ["#7"], "priority": "high" }),
|
json!({ "refs": ["#7"], "priority": "high" }),
|
||||||
),
|
),
|
||||||
|
(35, "idea_ticket_bulk_delete", json!({ "refs": ["#7"] })),
|
||||||
(
|
(
|
||||||
35,
|
36,
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
36,
|
37,
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import type {
|
|||||||
Sprint,
|
Sprint,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketBulkResult,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
@ -297,6 +298,21 @@ export class HttpTicketGateway implements TicketGateway {
|
|||||||
async delete(projectId: string, ref: string): Promise<void> {
|
async delete(projectId: string, ref: string): Promise<void> {
|
||||||
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
|
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> {
|
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||||
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,7 @@ import type {
|
|||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketBulkResult,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
TicketLink,
|
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> {
|
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||||
const ticket = this.require(projectId, ref);
|
const ticket = this.require(projectId, ref);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type {
|
|||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
Sprint,
|
Sprint,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketBulkResult,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
TicketLinkKind,
|
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> {
|
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||||
return invoke<TicketCarnet>("ticket_read_carnet", {
|
return invoke<TicketCarnet>("ticket_read_carnet", {
|
||||||
request: { projectId, ref },
|
request: { projectId, ref },
|
||||||
|
|||||||
@ -1538,6 +1538,19 @@ export interface TicketList {
|
|||||||
nextCursor?: string;
|
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`). */
|
/** The scoped-to-a-ticket Markdown carnet (mirror of `TicketCarnetDto`). */
|
||||||
export interface TicketCarnet {
|
export interface TicketCarnet {
|
||||||
ref: TicketRef;
|
ref: TicketRef;
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
* through {@link useTickets}.
|
* 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 { Button, Input, Panel, Spinner } from "@/shared";
|
||||||
import { useTickets } from "./useTickets";
|
import { useTickets } from "./useTickets";
|
||||||
import { useProjectAgents } from "./useProjectAgents";
|
import { useProjectAgents } from "./useProjectAgents";
|
||||||
@ -21,8 +21,10 @@ import {
|
|||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TICKET_PRIORITIES,
|
TICKET_PRIORITIES,
|
||||||
|
TICKET_STATUSES,
|
||||||
TicketRef,
|
TicketRef,
|
||||||
priorityLabel,
|
priorityLabel,
|
||||||
|
statusLabel,
|
||||||
} from "./ticketMeta";
|
} from "./ticketMeta";
|
||||||
|
|
||||||
export interface TicketsPanelProps {
|
export interface TicketsPanelProps {
|
||||||
@ -56,8 +58,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
// picker popup is open (#38).
|
// picker popup is open (#38).
|
||||||
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
|
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
|
||||||
const [showSprintPicker, setShowSprintPicker] = useState(false);
|
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 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
|
// Group tickets by sprint (ticket #10): one ordered section per sprint that
|
||||||
// holds tickets, then a "Sans sprint" bucket. Tickets referencing an unknown
|
// holds tickets, then a "Sans sprint" bucket. Tickets referencing an unknown
|
||||||
@ -94,6 +111,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
onOpen(created.ref);
|
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 (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title="Tickets"
|
title="Tickets"
|
||||||
@ -134,6 +178,47 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
</p>
|
</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 && (
|
{showCreate && (
|
||||||
<form
|
<form
|
||||||
onSubmit={submitCreate}
|
onSubmit={submitCreate}
|
||||||
@ -243,6 +328,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
nameOf={nameOf}
|
nameOf={nameOf}
|
||||||
onAssignSprint={vm.assignSprint}
|
onAssignSprint={vm.assignSprint}
|
||||||
|
selectedRefs={selectedRefs}
|
||||||
|
onToggleSelected={toggleTicketSelection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{noSprint.length > 0 && (
|
{noSprint.length > 0 && (
|
||||||
@ -254,6 +341,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
nameOf={nameOf}
|
nameOf={nameOf}
|
||||||
onAssignSprint={vm.assignSprint}
|
onAssignSprint={vm.assignSprint}
|
||||||
|
selectedRefs={selectedRefs}
|
||||||
|
onToggleSelected={toggleTicketSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -295,6 +384,44 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
onSelect={(sprint) => setNewSprint(sprint)}
|
onSelect={(sprint) => setNewSprint(sprint)}
|
||||||
onClose={() => setShowSprintPicker(false)}
|
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>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -312,6 +439,8 @@ function SprintSection({
|
|||||||
onOpen,
|
onOpen,
|
||||||
nameOf,
|
nameOf,
|
||||||
onAssignSprint,
|
onAssignSprint,
|
||||||
|
selectedRefs,
|
||||||
|
onToggleSelected,
|
||||||
}: {
|
}: {
|
||||||
heading: string;
|
heading: string;
|
||||||
count: number;
|
count: number;
|
||||||
@ -320,6 +449,8 @@ function SprintSection({
|
|||||||
onOpen: (ref: string) => void;
|
onOpen: (ref: string) => void;
|
||||||
nameOf: (id: string) => string;
|
nameOf: (id: string) => string;
|
||||||
onAssignSprint: (ref: string, sprintId: string | null) => void;
|
onAssignSprint: (ref: string, sprintId: string | null) => void;
|
||||||
|
selectedRefs: Set<string>;
|
||||||
|
onToggleSelected: (ref: string, selected: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section aria-label={`sprint section ${heading}`}>
|
<section aria-label={`sprint section ${heading}`}>
|
||||||
@ -333,6 +464,13 @@ function SprintSection({
|
|||||||
{tickets.map((t) => (
|
{tickets.map((t) => (
|
||||||
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
|
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
|
||||||
<div className="flex items-start gap-2">
|
<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" />
|
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
|
||||||
<button
|
<button
|
||||||
type="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 () => {
|
it("links and unlinks tickets, bumping the version", async () => {
|
||||||
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
||||||
await ticket.create(PROJECT_ID, { title: "b" });
|
await ticket.create(PROJECT_ID, { title: "b" });
|
||||||
@ -692,6 +730,56 @@ describe("TicketsView", () => {
|
|||||||
expect(await screen.findByText("No tickets.")).toBeTruthy();
|
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 () => {
|
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
|
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
|
||||||
const delSpy = vi.spyOn(ticket, "delete");
|
const delSpy = vi.spyOn(ticket, "delete");
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
type GatewayError,
|
type GatewayError,
|
||||||
type Sprint,
|
type Sprint,
|
||||||
type Ticket,
|
type Ticket,
|
||||||
|
type TicketBulkResult,
|
||||||
type TicketList,
|
type TicketList,
|
||||||
type TicketPriority,
|
type TicketPriority,
|
||||||
type TicketStatus,
|
type TicketStatus,
|
||||||
@ -50,6 +51,15 @@ export interface TicketsViewModel {
|
|||||||
clearFacets: () => void;
|
clearFacets: () => void;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
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
|
* 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
|
||||||
@ -79,6 +89,14 @@ function describe(e: unknown): string {
|
|||||||
return String(e);
|
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 {
|
export function useTickets(projectId: string): TicketsViewModel {
|
||||||
const { ticket, system, uiPreferences } = useGateways();
|
const { ticket, system, uiPreferences } = useGateways();
|
||||||
const [list, setList] = useState<TicketList | null>(null);
|
const [list, setList] = useState<TicketList | null>(null);
|
||||||
@ -178,6 +196,75 @@ export function useTickets(projectId: string): TicketsViewModel {
|
|||||||
[projectId, ticket, refresh],
|
[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(
|
const assignSprint = useCallback(
|
||||||
async (ref: string, sprintId: string | null): Promise<boolean> => {
|
async (ref: string, sprintId: string | null): Promise<boolean> => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -313,6 +400,9 @@ export function useTickets(projectId: string): TicketsViewModel {
|
|||||||
clearFacets,
|
clearFacets,
|
||||||
refresh,
|
refresh,
|
||||||
create,
|
create,
|
||||||
|
bulkUpdateStatus,
|
||||||
|
bulkUpdatePriority,
|
||||||
|
bulkDelete,
|
||||||
assignSprint,
|
assignSprint,
|
||||||
createSprint,
|
createSprint,
|
||||||
renameSprint,
|
renameSprint,
|
||||||
|
|||||||
@ -67,6 +67,7 @@ import type {
|
|||||||
Template,
|
Template,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketBulkResult,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
@ -1067,6 +1068,20 @@ export interface TicketGateway {
|
|||||||
* of an open detail view. Callers must not mutate local state imperatively.
|
* of an open detail view. Callers must not mutate local state imperatively.
|
||||||
*/
|
*/
|
||||||
delete(projectId: string, ref: string): Promise<void>;
|
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. */
|
/** Reads the ticket-scoped Markdown carnet. */
|
||||||
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
|
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
|
||||||
/** Replaces (not appends) the ticket-scoped carnet body. */
|
/** Replaces (not appends) the ticket-scoped carnet body. */
|
||||||
|
|||||||
Reference in New Issue
Block a user