feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)

Passe les filtres de la liste des tickets en sélection multiple (#12).

Backend Rust :
- IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ
  et AND inter-champs
- TicketListRequestDto en tableaux + from_request (parse/déduplication)
- MCP idea_ticket_list aligné sur le nouveau contrat

Frontend :
- TicketListQuery.statuses/priorities
- UI de cases à cocher, toggle/clear des filtres

Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24),
issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:41:01 +02:00
parent 413db25f08
commit feeca3462c
11 changed files with 410 additions and 81 deletions

View File

@ -195,8 +195,10 @@ pub struct TicketDeleteRequestDto {
pub struct TicketListRequestDto {
#[serde(default)]
pub project_id: String,
pub status: Option<String>,
pub priority: Option<String>,
#[serde(default)]
pub statuses: Vec<String>,
#[serde(default)]
pub priorities: Vec<String>,
pub assigned_agent_id: Option<String>,
pub sprint_id: Option<String>,
pub text: Option<String>,
@ -1109,6 +1111,7 @@ impl From<IssueCarnet> for TicketCarnetDto {
}
}
#[derive(Debug)]
struct TicketListPageInput {
filter: IssueListFilter,
limit: usize,
@ -1119,16 +1122,8 @@ impl TicketListPageInput {
fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
Ok(Self {
filter: IssueListFilter {
status: request
.status
.as_deref()
.map(parse_status_dto)
.transpose()?,
priority: request
.priority
.as_deref()
.map(parse_priority_dto)
.transpose()?,
statuses: parse_statuses_dto(request.statuses)?,
priorities: parse_priorities_dto(request.priorities)?,
assigned_agent_id: request
.assigned_agent_id
.as_deref()
@ -1349,6 +1344,28 @@ fn parse_priority_dto(raw: &str) -> Result<IssuePriority, ErrorDto> {
parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message))
}
fn parse_statuses_dto(raw: Vec<String>) -> Result<Vec<IssueStatus>, ErrorDto> {
let mut out = Vec::new();
for item in raw {
let status = parse_status_dto(&item)?;
if !out.contains(&status) {
out.push(status);
}
}
Ok(out)
}
fn parse_priorities_dto(raw: Vec<String>) -> Result<Vec<IssuePriority>, ErrorDto> {
let mut out = Vec::new();
for item in raw {
let priority = parse_priority_dto(&item)?;
if !out.contains(&priority) {
out.push(priority);
}
}
Ok(out)
}
fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
}
@ -1524,3 +1541,99 @@ impl ErrorDto {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::IssueNumber;
#[test]
fn ticket_list_request_deduplicates_multi_select_filters() {
let page = TicketListPageInput::from_request(TicketListRequestDto {
project_id: String::new(),
statuses: vec!["open".into(), "closed".into(), "open".into()],
priorities: vec!["high".into(), "low".into(), "high".into()],
assigned_agent_id: None,
sprint_id: None,
text: None,
limit: None,
cursor: None,
})
.unwrap();
assert_eq!(
page.filter.statuses,
vec![IssueStatus::Open, IssueStatus::Closed]
);
assert_eq!(
page.filter.priorities,
vec![IssuePriority::High, IssuePriority::Low]
);
}
#[test]
fn ticket_list_request_rejects_invalid_multi_select_token() {
let err = TicketListPageInput::from_request(TicketListRequestDto {
project_id: String::new(),
statuses: vec!["open".into(), "bad".into()],
priorities: Vec::new(),
assigned_agent_id: None,
sprint_id: None,
text: None,
limit: None,
cursor: None,
})
.unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("invalid ticket status: bad"));
}
#[test]
fn ticket_list_pagination_preserves_multi_filter_request_shape() {
let page = TicketListPageInput::from_request(TicketListRequestDto {
project_id: String::new(),
statuses: vec!["open".into(), "QA".into()],
priorities: vec!["high".into()],
assigned_agent_id: None,
sprint_id: None,
text: None,
limit: Some(1),
cursor: Some("1".into()),
})
.unwrap();
let rows = vec![
issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"),
issue_row(2, IssueStatus::Qa, IssuePriority::High, "Beta"),
issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
];
assert_eq!(
page.filter.statuses,
vec![IssueStatus::Open, IssueStatus::Qa]
);
assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
let out = paginate(rows, page.limit, page.cursor);
assert_eq!(out.items.len(), 1);
assert_eq!(out.items[0].r#ref, "#2");
assert_eq!(out.next_cursor, Some("2".to_owned()));
}
fn issue_row(
number: u64,
status: IssueStatus,
priority: IssuePriority,
title: &str,
) -> IssueIndexEntry {
IssueIndexEntry {
issue_ref: IssueRef::from(IssueNumber::new(number).unwrap()),
path: number.to_string(),
title: title.to_owned(),
status,
priority,
sprint: None,
assigned_agent_ids: Vec::new(),
updated_at: number,
}
}
}